From 85e93ba972b917083a297ac7f9f2cd9e6b20d43f Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:30:02 -0400 Subject: [PATCH 01/10] Publish test containers on host ports below the ephemeral range A port-0 bind and Docker's own choice both draw from 32768-60999, which a loaded runner's sockets fill. The resource reaper's port setting is marked obsolete but still read, and its test fails if an upgrade stops honouring it. Co-Authored-By: Claude Opus 5.5 --- .../ContainerHostPorts.cs | 83 +++++++++++++++++++ .../ContainerHostPortsTests.cs | 56 +++++++++++++ .../WorkOSEmulator.cs | 20 +---- .../ContainerHostPortGuardTests.cs | 51 ++++++++++++ 4 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 test/Capacitor.Cli.Tests.Integration/ContainerHostPorts.cs create mode 100644 test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs create mode 100644 test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs diff --git a/test/Capacitor.Cli.Tests.Integration/ContainerHostPorts.cs b/test/Capacitor.Cli.Tests.Integration/ContainerHostPorts.cs new file mode 100644 index 0000000000..bb2880ad34 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Integration/ContainerHostPorts.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Net.Sockets; +using System.Runtime.CompilerServices; +using Docker.DotNet; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; + +namespace Capacitor.Cli.Tests.Integration; + +/// +/// Host ports for every container a test publishes, Testcontainers' resource reaper included. Docker's own choice, and +/// a port-0 bind, both come from Linux's ephemeral range (32768–60999), which a loaded runner's outbound sockets and +/// their TIME_WAITs crowd, so a start fails with "address already in use" however often it is retried there. Below +/// that range only an explicit bind takes a port, so a pick is checked free here and walks on from where the last one +/// ended, which keeps two starts in one process from racing for the same port. +/// +public static class ContainerHostPorts { + public const int First = 20000; + public const int Last = 32767; + + const int Span = Last - First + 1; + + /// How many attempts a container start gets while each failure is a host-port clash. + const int MaxAttempts = 10; + + static int _cursor = Random.Shared.Next(Span); + + /// Marked obsolete, yet the reaper's port binding still reads it; the reaper's own pin fails the day a + /// Testcontainers upgrade stops honouring it. + [ModuleInitializer] + internal static void PublishTheResourceReaperHere() { +#pragma warning disable CS0618 + TestcontainersSettings.ResourceReaperPublicHostPort = _ => Next(); +#pragma warning restore CS0618 + } + + /// The next port in the range that nothing on this machine holds. + public static ushort Next() { + for (var tried = 0; tried < Span; tried++) { + var port = First + (int)((uint)Interlocked.Increment(ref _cursor) % Span); + if (IsFree(port)) return (ushort)port; + } + + throw new InvalidOperationException($"Every host port from {First} to {Last} is held"); + } + + /// Starts a container publishes on a fresh pick each time, + /// rebuilding it after each clash with another process that took the picked port first. + public static async Task StartAsync(Func build, CancellationToken ct = default) where T : IContainer { + for (var attempt = 1; ; attempt++) { + var container = build(); + + try { + await container.StartAsync(ct); + + return container; + } catch (Exception ex) when (IsClash(ex) && attempt < MaxAttempts) { + await Console.Error.WriteLineAsync($"{container.Image.FullName}: start attempt {attempt} hit a host-port clash, retrying: {ex.Message}"); + await container.DisposeAsync(); + await Task.Delay(TimeSpan.FromSeconds(1), ct); + } catch { + await container.DisposeAsync(); + throw; + } + } + } + + static bool IsClash(Exception ex) => + ex is DockerApiException { StatusCode: HttpStatusCode.InternalServerError } api + && api.ResponseBody.Contains("address already in use", StringComparison.Ordinal); + + static bool IsFree(int port) { + try { + var listener = new TcpListener(IPAddress.Any, port); + listener.Start(); + listener.Stop(); + + return true; + } catch (SocketException) { + return false; + } + } +} diff --git a/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs b/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs new file mode 100644 index 0000000000..e5d1a31cc3 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs @@ -0,0 +1,56 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; + +namespace Capacitor.Cli.Tests.Integration; + +/// Every container a test starts is published below Linux's ephemeral range, from which Docker's own choice +/// and a port-0 bind are drawn, on a port nothing here holds. The pick walks a process-wide cursor, so the test that +/// needs to know which port comes next runs alone. +public class ContainerHostPortsTests { + const int EphemeralFloor = 32768; + + [Test] + [NotInParallel] + public async Task A_full_cycle_of_picks_stays_in_range_and_never_hands_out_a_held_port() { + var held = ContainerHostPorts.Next() + 1; + if (held > ContainerHostPorts.Last) held = ContainerHostPorts.First; + + using var holder = new TcpListener(IPAddress.Any, held); + try { + holder.Start(); + } catch (SocketException) { + // something else already holds it, which serves as well + } + + var drawn = Enumerable.Range(0, ContainerHostPorts.Last - ContainerHostPorts.First + 1) + .Select(_ => (int)ContainerHostPorts.Next()) + .ToList(); + + await Assert.That(drawn).DoesNotContain(held); + await Assert.That(drawn.All(p => p is >= ContainerHostPorts.First and < EphemeralFloor)).IsTrue(); + } + + /// The resource reaper starts with the first container of the process, so it is running by the time the + /// emulator is up. + [Test] + [Timeout(300_000)] + public async Task The_emulator_and_the_resource_reaper_publish_below_the_ephemeral_range(CancellationToken ct) { + Skip.When(Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true" && !OperatingSystem.IsLinux(), + "this CI runner cannot run Linux containers"); + + var unavailable = await WorkOSEmulator.UnavailableReasonAsync(); + Skip.When(unavailable is not null, unavailable ?? ""); + + await using var emulator = await WorkOSEmulator.StartAsync(ct); + + using var docker = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientBuilder(Guid.Empty).Build(); + var reaper = await docker.Containers.InspectContainerAsync($"testcontainers-ryuk-{ResourceReaper.DefaultSessionId:D}", ct); + var reaperPort = int.Parse(reaper.NetworkSettings.Ports["8080/tcp"][0].HostPort, CultureInfo.InvariantCulture); + + await Assert.That(new Uri(emulator.BaseUrl).Port).IsLessThan(EphemeralFloor); + await Assert.That(reaperPort).IsLessThan(EphemeralFloor); + } +} diff --git a/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs b/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs index e3991f209a..26083fcebc 100644 --- a/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs +++ b/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs @@ -1,5 +1,4 @@ using System.Text; -using Capacitor.Cli.Core.Auth; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Configurations; using DotNet.Testcontainers.Containers; @@ -70,25 +69,14 @@ public sealed class WorkOSEmulator : IAsyncDisposable { public static async Task StartAsync(CancellationToken ct) { // A fixed host port, so a restart keeps the base a stored token names. Passing any argument // replaces the image's defaults, and without --host 0.0.0.0 it listens on its own loopback. - var hostPort = OAuthLoginFlow.GetAvailablePort(); - var container = new ContainerBuilder(Image) - .WithPortBinding(hostPort, Port) + var container = await ContainerHostPorts.StartAsync(() => new ContainerBuilder(Image) + .WithPortBinding(ContainerHostPorts.Next(), Port) .WithResourceMapping(Encoding.UTF8.GetBytes(Seed), SeedPath) .WithCommand("--host", "0.0.0.0", "--seed", SeedPath) .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r.ForPort(Port).ForPath("/health"))) - .Build(); + .Build(), ct); - var emulator = new WorkOSEmulator(container, hostPort); - - try { - await container.StartAsync(ct); - } catch { - await emulator.DisposeAsync(); - - throw; - } - - return emulator; + return new WorkOSEmulator(container, container.GetMappedPublicPort(Port)); } /// Stops and starts the same container: the emulator keeps all state in memory, so every diff --git a/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs new file mode 100644 index 0000000000..b3e577bdd3 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs @@ -0,0 +1,51 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; + +namespace Capacitor.Cli.Tests.Unit; + +/// +/// Docker's own host-port choice, and a port-0 bind, share Linux's ephemeral range with a loaded runner's sockets, so +/// every published container port takes its host port from ContainerHostPorts.Next(). Pinned as a guard because +/// a binding on Docker's choice passes everywhere a machine is quiet and fails only on a busy runner. +/// +public partial class ContainerHostPortGuardTests { + [GeneratedRegex(@"\.WithPortBinding\s*\((?!\s*ContainerHostPorts\.Next\s*\(\s*\)\s*,)")] + private static partial Regex DockerChosenBinding(); + + [Test] + [Arguments(".WithPortBinding(4100, assignRandomHostPort: true)")] + [Arguments(".WithPortBinding(4100, true)")] + [Arguments(".WithPortBinding(hostPort, Port)")] + [Arguments(".WithPortBinding(OAuthLoginFlow.GetAvailablePort(), Port)")] + public async Task Pattern_flags_a_port_docker_chooses(string code) => + await Assert.That(DockerChosenBinding().IsMatch(code)).IsTrue(); + + [Test] + public async Task Pattern_passes_a_port_picked_below_the_ephemeral_range() => + await Assert.That(DockerChosenBinding().IsMatch(".WithPortBinding(ContainerHostPorts.Next(), Port)")).IsFalse(); + + [Test] + public async Task No_test_publishes_a_container_port_on_dockers_choice() { + var root = RepoRoot(); + + var offenders = Directory.EnumerateFiles(Path.Combine(root, "test"), "*.cs", SearchOption.AllDirectories) + .Where(path => !path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Any(s => s is "bin" or "obj")) + .Where(path => path != ThisFile()) + .Where(path => DockerChosenBinding().IsMatch(File.ReadAllText(path))) + .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .ToList(); + + await Assert.That(offenders).IsEmpty() + .Because("bind each published port with .WithPortBinding(ContainerHostPorts.Next(), containerPort)"); + } + + static string ThisFile([CallerFilePath] string here = "") => here; + + static string RepoRoot([CallerFilePath] string here = "") { + var dir = Path.GetDirectoryName(here); + while (dir is not null && !File.Exists(Path.Combine(dir, "Capacitor.slnx"))) + dir = Path.GetDirectoryName(dir); + + return dir ?? throw new InvalidOperationException($"Could not locate repo root walking up from {here}"); + } +} From 2dedff351a3ecc8d51a878727b566e88e7f7cf75 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:37:36 -0400 Subject: [PATCH 02/10] Build the WorkOS emulator from a typed image reference Testcontainers parses an image string with a regex whose 5-second timeout a starved runner can exceed; only new DockerImage(repository, tag: ...) skips the parse. Co-Authored-By: Claude Opus 5.5 --- .../WorkOSEmulator.cs | 4 +- .../ContainerImageStringGuardTests.cs | 60 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs diff --git a/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs b/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs index 26083fcebc..93df9f3264 100644 --- a/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs +++ b/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs @@ -2,6 +2,7 @@ using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Configurations; using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Images; namespace Capacitor.Cli.Tests.Integration; @@ -14,7 +15,6 @@ public sealed class WorkOSEmulator : IAsyncDisposable { public const string OwnerId = "user_01K0000000000000000000000A"; public const string OrganizationId = "org_01K0000000000000000000000K"; - const string Image = "ghcr.io/workos/emulate:0.15.0"; const int Port = 4100; const string SeedPath = "/app/workos-emulate.config.yaml"; @@ -69,7 +69,7 @@ public sealed class WorkOSEmulator : IAsyncDisposable { public static async Task StartAsync(CancellationToken ct) { // A fixed host port, so a restart keeps the base a stored token names. Passing any argument // replaces the image's defaults, and without --host 0.0.0.0 it listens on its own loopback. - var container = await ContainerHostPorts.StartAsync(() => new ContainerBuilder(Image) + var container = await ContainerHostPorts.StartAsync(() => new ContainerBuilder(new DockerImage("ghcr.io/workos/emulate", tag: "0.15.0")) .WithPortBinding(ContainerHostPorts.Next(), Port) .WithResourceMapping(Encoding.UTF8.GetBytes(Seed), SeedPath) .WithCommand("--host", "0.0.0.0", "--seed", SeedPath) diff --git a/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs new file mode 100644 index 0000000000..8c7cfddb87 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs @@ -0,0 +1,60 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; + +namespace Capacitor.Cli.Tests.Unit; + +/// +/// Testcontainers parses an image string with a regex whose 5-second match timeout a starved runner can exceed; only +/// new DockerImage(repository, tag: …) skips the parse. So a builder or WithImage argument must be that +/// call, matched with string contents blanked so a literal containing tag: cannot pass for the named argument. +/// +public partial class ContainerImageStringGuardTests { + [GeneratedRegex(@"new\s+\w*ContainerBuilder\s*\((?!\s*new\s+DockerImage\s*\()|\.WithImage\s*\((?!\s*new\s+DockerImage\s*\()|new\s+DockerImage\s*\((?![^()]*\btag\s*:)")] + private static partial Regex ImageString(); + + [GeneratedRegex("\"\"\"[\\s\\S]*?\"\"\"|@\"(?:[^\"]|\"\")*\"|\"(?:[^\"\\\\\\n]|\\\\.)*\"")] + private static partial Regex StringLiteral(); + + [Test] + [Arguments("new ContainerBuilder(Image)")] + [Arguments("new ContainerBuilder(\"ghcr.io/workos/emulate:0.15.0\")")] + [Arguments("new ContainerBuilder($\"alpine:{tag}\")")] + [Arguments("new ContainerBuilder(\"\"\"alpine:3\"\"\")")] + [Arguments("new ContainerBuilder(\"tag:3\")")] + [Arguments("new DockerImage(\"alpine:3\")")] + [Arguments("new DockerImage(\"tag:3\")")] + [Arguments(".WithImage(\"alpine:3\")")] + public async Task Pattern_matches_an_untyped_image(string code) => + await Assert.That(UsesAnImageString(code)).IsTrue(); + + [Test] + [Arguments("new ContainerBuilder(new DockerImage(\"ghcr.io/workos/emulate\", tag: \"0.15.0\"))")] + [Arguments(".WithImage(new DockerImage(\"alpine\", tag: \"3\"))")] + [Arguments("new StringBuilder(\"text\")")] + public async Task Pattern_passes_a_typed_image(string code) => + await Assert.That(UsesAnImageString(code)).IsFalse(); + + [Test] + public async Task No_test_builds_a_container_from_an_untyped_image() { + var root = RepoRoot(); + + var offenders = Directory.EnumerateFiles(Path.Combine(root, "test"), "*.cs", SearchOption.AllDirectories) + .Where(path => !path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Any(s => s is "bin" or "obj")) + .Where(path => UsesAnImageString(File.ReadAllText(path))) + .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .ToList(); + + await Assert.That(offenders).IsEmpty() + .Because("pass new DockerImage(repository, tag: …) so Testcontainers never runs its timed image-name regex on it"); + } + + static bool UsesAnImageString(string code) => ImageString().IsMatch(StringLiteral().Replace(code, "\"\"")); + + static string RepoRoot([CallerFilePath] string here = "") { + var dir = Path.GetDirectoryName(here); + while (dir is not null && !File.Exists(Path.Combine(dir, "Capacitor.slnx"))) + dir = Path.GetDirectoryName(dir); + + return dir ?? throw new InvalidOperationException($"Could not locate repo root walking up from {here}"); + } +} From bde1f93e62324abf3f2f6d41a24b457baac88df5 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:22:25 -0400 Subject: [PATCH 03/10] Retry an allocated port, check IPv6 and pin each builder chain Two processes picking the same port get Docker's own "port is already allocated", not the kernel's "address already in use". Co-Authored-By: Claude Opus 5.5 --- .../ContainerHostPorts.cs | 26 ++++++++-- .../ContainerHostPortsTests.cs | 51 +++++++++++++++---- .../ContainerHostPortGuardTests.cs | 39 ++++++++++++-- 3 files changed, 97 insertions(+), 19 deletions(-) diff --git a/test/Capacitor.Cli.Tests.Integration/ContainerHostPorts.cs b/test/Capacitor.Cli.Tests.Integration/ContainerHostPorts.cs index bb2880ad34..d3d0afa3a6 100644 --- a/test/Capacitor.Cli.Tests.Integration/ContainerHostPorts.cs +++ b/test/Capacitor.Cli.Tests.Integration/ContainerHostPorts.cs @@ -65,19 +65,35 @@ public static async Task StartAsync(Func build, CancellationToken ct = } } - static bool IsClash(Exception ex) => + /// "address already in use" when a process holds the picked port, "port is already allocated" when another + /// container published it first. + internal static bool IsClash(Exception ex) => ex is DockerApiException { StatusCode: HttpStatusCode.InternalServerError } api - && api.ResponseBody.Contains("address already in use", StringComparison.Ordinal); + && (api.ResponseBody.Contains("address already in use", StringComparison.Ordinal) + || api.ResponseBody.Contains("port is already allocated", StringComparison.Ordinal)); + // Docker publishes on IPv6 as well as IPv4, so a port held on either is taken. A host without IPv6 refuses that + // bind for its own reasons, which say nothing about the port. static bool IsFree(int port) { try { - var listener = new TcpListener(IPAddress.Any, port); - listener.Start(); - listener.Stop(); + using var v4 = new TcpListener(IPAddress.Any, port); + v4.Start(); + } catch (SocketException) { + return false; + } + if (!Socket.OSSupportsIPv6) return true; + + try { + using var v6 = new TcpListener(IPAddress.IPv6Any, port); + v6.Server.DualMode = false; + v6.Start(); + } catch (SocketException ex) when (ex.SocketErrorCode != SocketError.AddressAlreadyInUse) { return true; } catch (SocketException) { return false; } + + return true; } } diff --git a/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs b/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs index e5d1a31cc3..70b772c944 100644 --- a/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Net; using System.Net.Sockets; +using Docker.DotNet; using DotNet.Testcontainers.Configurations; using DotNet.Testcontainers.Containers; @@ -15,15 +16,8 @@ public class ContainerHostPortsTests { [Test] [NotInParallel] public async Task A_full_cycle_of_picks_stays_in_range_and_never_hands_out_a_held_port() { - var held = ContainerHostPorts.Next() + 1; - if (held > ContainerHostPorts.Last) held = ContainerHostPorts.First; - - using var holder = new TcpListener(IPAddress.Any, held); - try { - holder.Start(); - } catch (SocketException) { - // something else already holds it, which serves as well - } + var (held, holder) = HoldTheNextPort(); + using var _ = holder; var drawn = Enumerable.Range(0, ContainerHostPorts.Last - ContainerHostPorts.First + 1) .Select(_ => (int)ContainerHostPorts.Next()) @@ -33,6 +27,28 @@ public async Task A_full_cycle_of_picks_stays_in_range_and_never_hands_out_a_hel await Assert.That(drawn.All(p => p is >= ContainerHostPorts.First and < EphemeralFloor)).IsTrue(); } + /// Docker publishes on IPv6 as well as IPv4, so a port something holds on IPv6 alone is just as taken. + [Test] + [NotInParallel] + public async Task A_port_held_on_ipv6_alone_is_passed_over() { + Skip.When(!Socket.OSSupportsIPv6, "this host has no IPv6"); + + var (held, holder) = HoldTheNextPort(ipv6Only: true); + using var _ = holder; + + await Assert.That((int)ContainerHostPorts.Next()).IsNotEqualTo(held); + } + + /// Docker refuses a taken host port in two words: the kernel's, when another process holds it, and its own, + /// when another container already published it. + [Test] + [Arguments("failed to bind port 0.0.0.0:33201/tcp: Error starting userland proxy: listen tcp4 0.0.0.0:33201: bind: address already in use", true)] + [Arguments("Bind for 0.0.0.0:20417 failed: port is already allocated", true)] + [Arguments("could not kill container: abc is zombie and can not be killed", false)] + public async Task Only_a_bind_refusal_is_a_host_port_clash(string message, bool clash) => + await Assert.That(ContainerHostPorts.IsClash(new DockerApiException(HttpStatusCode.InternalServerError, + $"{{\"message\":\"driver failed programming external connectivity on endpoint gifted_wozniak (8b56): {message}\"}}"))).IsEqualTo(clash); + /// The resource reaper starts with the first container of the process, so it is running by the time the /// emulator is up. [Test] @@ -53,4 +69,21 @@ public async Task The_emulator_and_the_resource_reaper_publish_below_the_ephemer await Assert.That(new Uri(emulator.BaseUrl).Port).IsLessThan(EphemeralFloor); await Assert.That(reaperPort).IsLessThan(EphemeralFloor); } + + /// Holds the port one past the latest pick, which is the one the next pick lands on. + static (int Port, TcpListener Holder) HoldTheNextPort(bool ipv6Only = false) { + var port = ContainerHostPorts.Next() + 1; + if (port > ContainerHostPorts.Last) port = ContainerHostPorts.First; + + var holder = new TcpListener(ipv6Only ? IPAddress.IPv6Any : IPAddress.Any, port); + if (ipv6Only) holder.Server.DualMode = false; + + try { + holder.Start(); + } catch (SocketException) { + // something else already holds it, which serves as well + } + + return (port, holder); + } } diff --git a/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs index b3e577bdd3..2c7a268f12 100644 --- a/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs @@ -6,23 +6,40 @@ namespace Capacitor.Cli.Tests.Unit; /// /// Docker's own host-port choice, and a port-0 bind, share Linux's ephemeral range with a loaded runner's sockets, so /// every published container port takes its host port from ContainerHostPorts.Next(). Pinned as a guard because -/// a binding on Docker's choice passes everywhere a machine is quiet and fails only on a busy runner. +/// a binding on Docker's choice passes everywhere a machine is quiet and fails only on a busy runner. The KurrentDB and +/// Postgres builders publish their port on Docker's choice unless told otherwise, so each builder's own chain, up to +/// its .Build(), needs a picked binding. /// public partial class ContainerHostPortGuardTests { [GeneratedRegex(@"\.WithPortBinding\s*\((?!\s*ContainerHostPorts\.Next\s*\(\s*\)\s*,)")] private static partial Regex DockerChosenBinding(); + [GeneratedRegex(@"new\s+(?:KurrentDb|PostgreSql)Builder\s*\(|(?:KurrentDb|PostgreSql)Builder\s+\w+\s*=\s*new\s*\(")] + private static partial Regex PublishingBuilder(); + + [GeneratedRegex(@"\.WithPortBinding\s*\(\s*ContainerHostPorts\.Next\s*\(\s*\)\s*,")] + private static partial Regex PickedBinding(); + + [GeneratedRegex(@"\.Build\s*\(\s*\)")] + private static partial Regex Build(); + [Test] [Arguments(".WithPortBinding(4100, assignRandomHostPort: true)")] [Arguments(".WithPortBinding(4100, true)")] [Arguments(".WithPortBinding(hostPort, Port)")] [Arguments(".WithPortBinding(OAuthLoginFlow.GetAvailablePort(), Port)")] + [Arguments("new KurrentDbBuilder(image).Build()")] + [Arguments("KurrentDbBuilder b = new(image); b.Build()")] + [Arguments("new PostgreSqlBuilder(image).Build(); new ContainerBuilder(i).WithPortBinding(ContainerHostPorts.Next(), 80).Build()")] public async Task Pattern_flags_a_port_docker_chooses(string code) => - await Assert.That(DockerChosenBinding().IsMatch(code)).IsTrue(); + await Assert.That(PublishesOnDockersChoice(code)).IsTrue(); [Test] - public async Task Pattern_passes_a_port_picked_below_the_ephemeral_range() => - await Assert.That(DockerChosenBinding().IsMatch(".WithPortBinding(ContainerHostPorts.Next(), Port)")).IsFalse(); + [Arguments(".WithPortBinding(ContainerHostPorts.Next(), Port)")] + [Arguments("new KurrentDbBuilder(image).WithPortBinding(ContainerHostPorts.Next(), 2113).Build()")] + [Arguments("KurrentDbBuilder b = new(image); b = b.WithPortBinding(ContainerHostPorts.Next(), 2113); b.Build()")] + public async Task Pattern_passes_a_port_picked_below_the_ephemeral_range(string code) => + await Assert.That(PublishesOnDockersChoice(code)).IsFalse(); [Test] public async Task No_test_publishes_a_container_port_on_dockers_choice() { @@ -31,7 +48,7 @@ public async Task No_test_publishes_a_container_port_on_dockers_choice() { var offenders = Directory.EnumerateFiles(Path.Combine(root, "test"), "*.cs", SearchOption.AllDirectories) .Where(path => !path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Any(s => s is "bin" or "obj")) .Where(path => path != ThisFile()) - .Where(path => DockerChosenBinding().IsMatch(File.ReadAllText(path))) + .Where(path => PublishesOnDockersChoice(File.ReadAllText(path))) .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) .ToList(); @@ -39,6 +56,18 @@ await Assert.That(offenders).IsEmpty() .Because("bind each published port with .WithPortBinding(ContainerHostPorts.Next(), containerPort)"); } + static bool PublishesOnDockersChoice(string code) => + DockerChosenBinding().IsMatch(code) + || PublishingBuilder().Matches(code).Any(builder => !PickedBinding().IsMatch(ChainOf(code, builder))); + + /// The code from a builder's construction to its first .Build(), or to the end of the file. + static string ChainOf(string code, Match builder) { + var start = builder.Index + builder.Length; + var build = Build().Match(code, start); + + return code[start..(build.Success ? build.Index : code.Length)]; + } + static string ThisFile([CallerFilePath] string here = "") => here; static string RepoRoot([CallerFilePath] string here = "") { From 361b7ec27f7b4b1959e95df9bacf69135a23c73b Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:02:50 -0400 Subject: [PATCH 04/10] Blank strings in the port guard and end each builder chain at its statement Co-Authored-By: Claude Opus 5.5 --- .../WorkOSEmulator.cs | 10 +++--- .../ContainerHostPortGuardTests.cs | 33 ++++++++++++------- .../ContainerImageStringGuardTests.cs | 9 +++-- test/Capacitor.Cli.Tests.Unit/SourceCode.cs | 12 +++++++ 4 files changed, 43 insertions(+), 21 deletions(-) create mode 100644 test/Capacitor.Cli.Tests.Unit/SourceCode.cs diff --git a/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs b/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs index 93df9f3264..fee862cde1 100644 --- a/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs +++ b/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs @@ -67,8 +67,9 @@ public sealed class WorkOSEmulator : IAsyncDisposable { } public static async Task StartAsync(CancellationToken ct) { - // A fixed host port, so a restart keeps the base a stored token names. Passing any argument - // replaces the image's defaults, and without --host 0.0.0.0 it listens on its own loopback. + // The host port stays fixed across a restart, because a stored token names the base URL; something that takes + // the port while the emulator is stopped fails that one test. Passing any argument replaces the image's + // defaults, and without --host 0.0.0.0 it listens on its own loopback. var container = await ContainerHostPorts.StartAsync(() => new ContainerBuilder(new DockerImage("ghcr.io/workos/emulate", tag: "0.15.0")) .WithPortBinding(ContainerHostPorts.Next(), Port) .WithResourceMapping(Encoding.UTF8.GetBytes(Seed), SeedPath) @@ -79,8 +80,9 @@ public static async Task StartAsync(CancellationToken ct) { return new WorkOSEmulator(container, container.GetMappedPublicPort(Port)); } - /// Stops and starts the same container: the emulator keeps all state in memory, so every - /// token it issued is forgotten. + /// Stops and starts the same container on the same host port: the emulator keeps all state in memory, so + /// every token it issued is forgotten. The port is not retried, so one taken while it is stopped fails the + /// start. public async Task RestartAsync(CancellationToken ct) { await _container.StopAsync(ct); await _container.StartAsync(ct); diff --git a/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs index 2c7a268f12..a8cf72d269 100644 --- a/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs @@ -7,8 +7,8 @@ namespace Capacitor.Cli.Tests.Unit; /// Docker's own host-port choice, and a port-0 bind, share Linux's ephemeral range with a loaded runner's sockets, so /// every published container port takes its host port from ContainerHostPorts.Next(). Pinned as a guard because /// a binding on Docker's choice passes everywhere a machine is quiet and fails only on a busy runner. The KurrentDB and -/// Postgres builders publish their port on Docker's choice unless told otherwise, so each builder's own chain, up to -/// its .Build(), needs a picked binding. +/// Postgres builders publish their port on Docker's choice unless told otherwise, so each builder's own chain needs a +/// picked binding in the statement that constructs it. /// public partial class ContainerHostPortGuardTests { [GeneratedRegex(@"\.WithPortBinding\s*\((?!\s*ContainerHostPorts\.Next\s*\(\s*\)\s*,)")] @@ -31,13 +31,16 @@ public partial class ContainerHostPortGuardTests { [Arguments("new KurrentDbBuilder(image).Build()")] [Arguments("KurrentDbBuilder b = new(image); b.Build()")] [Arguments("new PostgreSqlBuilder(image).Build(); new ContainerBuilder(i).WithPortBinding(ContainerHostPorts.Next(), 80).Build()")] + [Arguments("KurrentDbBuilder b = new(image); b = b.WithPortBinding(ContainerHostPorts.Next(), 2113); b.Build()")] + [Arguments("KurrentDbBuilder Unbuilt() => new KurrentDbBuilder(image); var c = new ContainerBuilder(i).WithPortBinding(ContainerHostPorts.Next(), 80).Build();")] + [Arguments("Pair(new KurrentDbBuilder(a), new PostgreSqlBuilder(b).WithPortBinding(ContainerHostPorts.Next(), 5432).Build())")] + [Arguments("new KurrentDbBuilder(image).WithEnvironment(\"NOTE\", \".WithPortBinding(ContainerHostPorts.Next(), 2113)\").Build()")] public async Task Pattern_flags_a_port_docker_chooses(string code) => await Assert.That(PublishesOnDockersChoice(code)).IsTrue(); [Test] [Arguments(".WithPortBinding(ContainerHostPorts.Next(), Port)")] [Arguments("new KurrentDbBuilder(image).WithPortBinding(ContainerHostPorts.Next(), 2113).Build()")] - [Arguments("KurrentDbBuilder b = new(image); b = b.WithPortBinding(ContainerHostPorts.Next(), 2113); b.Build()")] public async Task Pattern_passes_a_port_picked_below_the_ephemeral_range(string code) => await Assert.That(PublishesOnDockersChoice(code)).IsFalse(); @@ -47,7 +50,6 @@ public async Task No_test_publishes_a_container_port_on_dockers_choice() { var offenders = Directory.EnumerateFiles(Path.Combine(root, "test"), "*.cs", SearchOption.AllDirectories) .Where(path => !path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Any(s => s is "bin" or "obj")) - .Where(path => path != ThisFile()) .Where(path => PublishesOnDockersChoice(File.ReadAllText(path))) .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) .ToList(); @@ -56,19 +58,26 @@ await Assert.That(offenders).IsEmpty() .Because("bind each published port with .WithPortBinding(ContainerHostPorts.Next(), containerPort)"); } - static bool PublishesOnDockersChoice(string code) => - DockerChosenBinding().IsMatch(code) - || PublishingBuilder().Matches(code).Any(builder => !PickedBinding().IsMatch(ChainOf(code, builder))); + static bool PublishesOnDockersChoice(string source) { + var code = SourceCode.OutsideStrings(source); + + return DockerChosenBinding().IsMatch(code) + || PublishingBuilder().Matches(code).Any(builder => !PickedBinding().IsMatch(ChainOf(code, builder))); + } - /// The code from a builder's construction to its first .Build(), or to the end of the file. + /// The code from a builder's construction to whichever comes first: its .Build(), the end of its + /// statement, or the next builder's construction. A binding added in a later statement does not count. static string ChainOf(string code, Match builder) { var start = builder.Index + builder.Length; - var build = Build().Match(code, start); - return code[start..(build.Success ? build.Index : code.Length)]; - } + int[] ends = [ + Build().Match(code, start) is { Success: true } build ? build.Index : code.Length, + code.IndexOf(';', start) is >= 0 and var statement ? statement : code.Length, + PublishingBuilder().Match(code, start) is { Success: true } next ? next.Index : code.Length + ]; - static string ThisFile([CallerFilePath] string here = "") => here; + return code[start..ends.Min()]; + } static string RepoRoot([CallerFilePath] string here = "") { var dir = Path.GetDirectoryName(here); diff --git a/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs index 8c7cfddb87..7207d9abc5 100644 --- a/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs @@ -9,12 +9,9 @@ namespace Capacitor.Cli.Tests.Unit; /// call, matched with string contents blanked so a literal containing tag: cannot pass for the named argument. /// public partial class ContainerImageStringGuardTests { - [GeneratedRegex(@"new\s+\w*ContainerBuilder\s*\((?!\s*new\s+DockerImage\s*\()|\.WithImage\s*\((?!\s*new\s+DockerImage\s*\()|new\s+DockerImage\s*\((?![^()]*\btag\s*:)")] + [GeneratedRegex(@"new\s+\w*ContainerBuilder\s*\((?!\s*new\s+DockerImage\s*\()|\w*ContainerBuilder\s+\w+\s*=\s*new\s*\((?!\s*new\s+DockerImage\s*\()|\.WithImage\s*\((?!\s*new\s+DockerImage\s*\()|new\s+DockerImage\s*\((?![^()]*\btag\s*:)")] private static partial Regex ImageString(); - [GeneratedRegex("\"\"\"[\\s\\S]*?\"\"\"|@\"(?:[^\"]|\"\")*\"|\"(?:[^\"\\\\\\n]|\\\\.)*\"")] - private static partial Regex StringLiteral(); - [Test] [Arguments("new ContainerBuilder(Image)")] [Arguments("new ContainerBuilder(\"ghcr.io/workos/emulate:0.15.0\")")] @@ -24,6 +21,7 @@ public partial class ContainerImageStringGuardTests { [Arguments("new DockerImage(\"alpine:3\")")] [Arguments("new DockerImage(\"tag:3\")")] [Arguments(".WithImage(\"alpine:3\")")] + [Arguments("ContainerBuilder b = new(\"alpine:3\");")] public async Task Pattern_matches_an_untyped_image(string code) => await Assert.That(UsesAnImageString(code)).IsTrue(); @@ -31,6 +29,7 @@ public async Task Pattern_matches_an_untyped_image(string code) => [Arguments("new ContainerBuilder(new DockerImage(\"ghcr.io/workos/emulate\", tag: \"0.15.0\"))")] [Arguments(".WithImage(new DockerImage(\"alpine\", tag: \"3\"))")] [Arguments("new StringBuilder(\"text\")")] + [Arguments("ContainerBuilder b = new(new DockerImage(\"alpine\", tag: \"3\"));")] public async Task Pattern_passes_a_typed_image(string code) => await Assert.That(UsesAnImageString(code)).IsFalse(); @@ -48,7 +47,7 @@ await Assert.That(offenders).IsEmpty() .Because("pass new DockerImage(repository, tag: …) so Testcontainers never runs its timed image-name regex on it"); } - static bool UsesAnImageString(string code) => ImageString().IsMatch(StringLiteral().Replace(code, "\"\"")); + static bool UsesAnImageString(string code) => ImageString().IsMatch(SourceCode.OutsideStrings(code)); static string RepoRoot([CallerFilePath] string here = "") { var dir = Path.GetDirectoryName(here); diff --git a/test/Capacitor.Cli.Tests.Unit/SourceCode.cs b/test/Capacitor.Cli.Tests.Unit/SourceCode.cs new file mode 100644 index 0000000000..8e84007048 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/SourceCode.cs @@ -0,0 +1,12 @@ +using System.Text.RegularExpressions; + +namespace Capacitor.Cli.Tests.Unit; + +/// Source text for a guard that matches code shapes, with every string literal's content blanked, so a literal +/// that spells the shape can neither pass for it nor hide its absence. +public static partial class SourceCode { + [GeneratedRegex("\"\"\"[\\s\\S]*?\"\"\"|@\"(?:[^\"]|\"\")*\"|\"(?:[^\"\\\\\\n]|\\\\.)*\"")] + private static partial Regex StringLiteral(); + + public static string OutsideStrings(string code) => StringLiteral().Replace(code, "\"\""); +} From e80a214a1d2e02177369f049a1b21b469e1872f1 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:00:48 -0400 Subject: [PATCH 05/10] Blank char literals before strings in the source guards Co-Authored-By: Claude Opus 5.5 --- .../ContainerHostPortGuardTests.cs | 1 + test/Capacitor.Cli.Tests.Unit/SourceCode.cs | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs index a8cf72d269..489b72e63f 100644 --- a/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs @@ -35,6 +35,7 @@ public partial class ContainerHostPortGuardTests { [Arguments("KurrentDbBuilder Unbuilt() => new KurrentDbBuilder(image); var c = new ContainerBuilder(i).WithPortBinding(ContainerHostPorts.Next(), 80).Build();")] [Arguments("Pair(new KurrentDbBuilder(a), new PostgreSqlBuilder(b).WithPortBinding(ContainerHostPorts.Next(), 5432).Build())")] [Arguments("new KurrentDbBuilder(image).WithEnvironment(\"NOTE\", \".WithPortBinding(ContainerHostPorts.Next(), 2113)\").Build()")] + [Arguments("var q = '\"'; new KurrentDbBuilder(image).Build(); var s = \"x\";")] public async Task Pattern_flags_a_port_docker_chooses(string code) => await Assert.That(PublishesOnDockersChoice(code)).IsTrue(); diff --git a/test/Capacitor.Cli.Tests.Unit/SourceCode.cs b/test/Capacitor.Cli.Tests.Unit/SourceCode.cs index 8e84007048..654988011c 100644 --- a/test/Capacitor.Cli.Tests.Unit/SourceCode.cs +++ b/test/Capacitor.Cli.Tests.Unit/SourceCode.cs @@ -3,10 +3,11 @@ namespace Capacitor.Cli.Tests.Unit; /// Source text for a guard that matches code shapes, with every string literal's content blanked, so a literal -/// that spells the shape can neither pass for it nor hide its absence. +/// that spells the shape can neither pass for it nor hide its absence. A char literal is matched first, or the quote in +/// '"' would open a string that swallows the code after it. public static partial class SourceCode { - [GeneratedRegex("\"\"\"[\\s\\S]*?\"\"\"|@\"(?:[^\"]|\"\")*\"|\"(?:[^\"\\\\\\n]|\\\\.)*\"")] - private static partial Regex StringLiteral(); + [GeneratedRegex("'(?:\\\\u[0-9A-Fa-f]{4}|\\\\.|[^'\\\\\\n])'|\"\"\"[\\s\\S]*?\"\"\"|@\"(?:[^\"]|\"\")*\"|\"(?:[^\"\\\\\\n]|\\\\.)*\"")] + private static partial Regex Literal(); - public static string OutsideStrings(string code) => StringLiteral().Replace(code, "\"\""); + public static string OutsideStrings(string code) => Literal().Replace(code, m => m.Value[0] == '\'' ? "' '" : "\"\""); } From 2b2cae92bacb7e55e70f5995fbc01013cefb7433 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:48:03 -0400 Subject: [PATCH 06/10] Strip comments in the source guards and end a builder chain at depth 0 A quote inside a comment opened a string that hid the code after it, and a lambda body's semicolon cut a builder chain short. Co-Authored-By: Claude Opus 5.5 --- .../ContainerHostPortGuardTests.cs | 22 +++++++++++++++++-- .../ContainerImageStringGuardTests.cs | 2 +- test/Capacitor.Cli.Tests.Unit/SourceCode.cs | 17 +++++++++----- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs index 489b72e63f..aeb23fbdec 100644 --- a/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs @@ -36,12 +36,15 @@ public partial class ContainerHostPortGuardTests { [Arguments("Pair(new KurrentDbBuilder(a), new PostgreSqlBuilder(b).WithPortBinding(ContainerHostPorts.Next(), 5432).Build())")] [Arguments("new KurrentDbBuilder(image).WithEnvironment(\"NOTE\", \".WithPortBinding(ContainerHostPorts.Next(), 2113)\").Build()")] [Arguments("var q = '\"'; new KurrentDbBuilder(image).Build(); var s = \"x\";")] + [Arguments("/* \" */ new KurrentDbBuilder(image).Build(); var s = \"x\";")] public async Task Pattern_flags_a_port_docker_chooses(string code) => await Assert.That(PublishesOnDockersChoice(code)).IsTrue(); [Test] [Arguments(".WithPortBinding(ContainerHostPorts.Next(), Port)")] [Arguments("new KurrentDbBuilder(image).WithPortBinding(ContainerHostPorts.Next(), 2113).Build()")] + [Arguments("new KurrentDbBuilder(image).WithCreateParameterModifier(p => { p.Name = \"k\"; }).WithPortBinding(ContainerHostPorts.Next(), 2113).Build()")] + [Arguments("// .WithPortBinding(2113, true)\n.WithPortBinding(ContainerHostPorts.Next(), Port)")] public async Task Pattern_passes_a_port_picked_below_the_ephemeral_range(string code) => await Assert.That(PublishesOnDockersChoice(code)).IsFalse(); @@ -60,7 +63,7 @@ await Assert.That(offenders).IsEmpty() } static bool PublishesOnDockersChoice(string source) { - var code = SourceCode.OutsideStrings(source); + var code = SourceCode.WithoutCommentsOrStrings(source); return DockerChosenBinding().IsMatch(code) || PublishingBuilder().Matches(code).Any(builder => !PickedBinding().IsMatch(ChainOf(code, builder))); @@ -73,13 +76,28 @@ static string ChainOf(string code, Match builder) { int[] ends = [ Build().Match(code, start) is { Success: true } build ? build.Index : code.Length, - code.IndexOf(';', start) is >= 0 and var statement ? statement : code.Length, + StatementEnd(code, start), PublishingBuilder().Match(code, start) is { Success: true } next ? next.Index : code.Length ]; return code[start..ends.Min()]; } + /// The first ; outside every bracket opened after , which lies inside the builder's + /// own constructor call, so a statement inside a lambda in the chain does not end it. + static int StatementEnd(string code, int start) { + var depth = 1; + + for (var i = start; i < code.Length; i++) + switch (code[i]) { + case '(' or '[' or '{': depth++; break; + case ')' or ']' or '}': depth--; break; + case ';' when depth <= 0: return i; + } + + return code.Length; + } + static string RepoRoot([CallerFilePath] string here = "") { var dir = Path.GetDirectoryName(here); while (dir is not null && !File.Exists(Path.Combine(dir, "Capacitor.slnx"))) diff --git a/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs index 7207d9abc5..3a60e03386 100644 --- a/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs @@ -47,7 +47,7 @@ await Assert.That(offenders).IsEmpty() .Because("pass new DockerImage(repository, tag: …) so Testcontainers never runs its timed image-name regex on it"); } - static bool UsesAnImageString(string code) => ImageString().IsMatch(SourceCode.OutsideStrings(code)); + static bool UsesAnImageString(string code) => ImageString().IsMatch(SourceCode.WithoutCommentsOrStrings(code)); static string RepoRoot([CallerFilePath] string here = "") { var dir = Path.GetDirectoryName(here); diff --git a/test/Capacitor.Cli.Tests.Unit/SourceCode.cs b/test/Capacitor.Cli.Tests.Unit/SourceCode.cs index 654988011c..6eaa87c962 100644 --- a/test/Capacitor.Cli.Tests.Unit/SourceCode.cs +++ b/test/Capacitor.Cli.Tests.Unit/SourceCode.cs @@ -2,12 +2,17 @@ namespace Capacitor.Cli.Tests.Unit; -/// Source text for a guard that matches code shapes, with every string literal's content blanked, so a literal -/// that spells the shape can neither pass for it nor hide its absence. A char literal is matched first, or the quote in -/// '"' would open a string that swallows the code after it. +/// Source text for a guard that matches code shapes, with every comment removed and every string literal's content +/// blanked, so neither can spell the shape or hide its absence. One left-to-right scan takes whichever starts first, so a +/// quote inside a comment or a char literal such as '"' never opens a string that swallows the code after it. public static partial class SourceCode { - [GeneratedRegex("'(?:\\\\u[0-9A-Fa-f]{4}|\\\\.|[^'\\\\\\n])'|\"\"\"[\\s\\S]*?\"\"\"|@\"(?:[^\"]|\"\")*\"|\"(?:[^\"\\\\\\n]|\\\\.)*\"")] - private static partial Regex Literal(); + [GeneratedRegex("//[^\\n]*|/\\*[\\s\\S]*?\\*/|'(?:\\\\u[0-9A-Fa-f]{4}|\\\\.|[^'\\\\\\n])'|\"\"\"[\\s\\S]*?\"\"\"|@\"(?:[^\"]|\"\")*\"|\"(?:[^\"\\\\\\n]|\\\\.)*\"")] + private static partial Regex Token(); - public static string OutsideStrings(string code) => Literal().Replace(code, m => m.Value[0] == '\'' ? "' '" : "\"\""); + public static string WithoutCommentsOrStrings(string code) => + Token().Replace(code, m => m.Value[0] switch { + '/' => " ", + '\'' => "' '", + _ => "\"\"" + }); } From ef72c391865d4d1845f901d8a306efaab3139797 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:15:33 -0400 Subject: [PATCH 07/10] Judge only a builder's own chained calls in the source guards A binding or a Build() inside an argument belongs to something else, and a raw string's closing delimiter is as long as its opening one. Co-Authored-By: Claude Opus 5.5 --- .../ContainerHostPortsTests.cs | 6 +- .../ContainerHostPortGuardTests.cs | 57 +++++++++++-------- .../ContainerImageStringGuardTests.cs | 39 ++++++++++++- test/Capacitor.Cli.Tests.Unit/SourceCode.cs | 2 +- 4 files changed, 75 insertions(+), 29 deletions(-) diff --git a/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs b/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs index 70b772c944..26c23d266c 100644 --- a/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs @@ -62,11 +62,15 @@ public async Task The_emulator_and_the_resource_reaper_publish_below_the_ephemer await using var emulator = await WorkOSEmulator.StartAsync(ct); + await Assert.That(new Uri(emulator.BaseUrl).Port).IsLessThan(EphemeralFloor); + + // A host that turns the reaper off has none to inspect. + if (!TestcontainersSettings.ResourceReaperEnabled) return; + using var docker = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientBuilder(Guid.Empty).Build(); var reaper = await docker.Containers.InspectContainerAsync($"testcontainers-ryuk-{ResourceReaper.DefaultSessionId:D}", ct); var reaperPort = int.Parse(reaper.NetworkSettings.Ports["8080/tcp"][0].HostPort, CultureInfo.InvariantCulture); - await Assert.That(new Uri(emulator.BaseUrl).Port).IsLessThan(EphemeralFloor); await Assert.That(reaperPort).IsLessThan(EphemeralFloor); } diff --git a/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs index aeb23fbdec..c5cc751373 100644 --- a/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs @@ -11,16 +11,17 @@ namespace Capacitor.Cli.Tests.Unit; /// picked binding in the statement that constructs it. /// public partial class ContainerHostPortGuardTests { - [GeneratedRegex(@"\.WithPortBinding\s*\((?!\s*ContainerHostPorts\.Next\s*\(\s*\)\s*,)")] + // The (ushort, bool) overload with true, or assignRandomHostPort named, hands the host port back to Docker. + [GeneratedRegex(@"\.WithPortBinding\s*\((?!\s*ContainerHostPorts\.Next\s*\(\s*\)\s*,(?!\s*(?:true\b|assignRandomHostPort\s*:)))")] private static partial Regex DockerChosenBinding(); - [GeneratedRegex(@"new\s+(?:KurrentDb|PostgreSql)Builder\s*\(|(?:KurrentDb|PostgreSql)Builder\s+\w+\s*=\s*new\s*\(")] + [GeneratedRegex(@"new\s+(?:[\w.]+\.)?(?:KurrentDb|PostgreSql)Builder\s*\(|(?:[\w.]+\.)?(?:KurrentDb|PostgreSql)Builder\??\s+\w+\s*=\s*new\s*\(")] private static partial Regex PublishingBuilder(); - [GeneratedRegex(@"\.WithPortBinding\s*\(\s*ContainerHostPorts\.Next\s*\(\s*\)\s*,")] + [GeneratedRegex(@"\.WithPortBinding\s*\(\s*ContainerHostPorts\.Next\s*\(\s*\)\s*,(?!\s*(?:true\b|assignRandomHostPort\s*:))")] private static partial Regex PickedBinding(); - [GeneratedRegex(@"\.Build\s*\(\s*\)")] + [GeneratedRegex(@"\G\.Build\s*\(\s*\)")] private static partial Regex Build(); [Test] @@ -37,6 +38,11 @@ public partial class ContainerHostPortGuardTests { [Arguments("new KurrentDbBuilder(image).WithEnvironment(\"NOTE\", \".WithPortBinding(ContainerHostPorts.Next(), 2113)\").Build()")] [Arguments("var q = '\"'; new KurrentDbBuilder(image).Build(); var s = \"x\";")] [Arguments("/* \" */ new KurrentDbBuilder(image).Build(); var s = \"x\";")] + [Arguments(".WithPortBinding(ContainerHostPorts.Next(), true)")] + [Arguments("new Testcontainers.KurrentDb.KurrentDbBuilder(image).Build()")] + [Arguments("KurrentDbBuilder? b = new(image); b.Build()")] + [Arguments("new KurrentDbBuilder(image).WithCreateParameterModifier(_ => Side(new ContainerBuilder(i).WithPortBinding(ContainerHostPorts.Next(), 80))).Build()")] + [Arguments("var s = \"\"\"\"x\"\"\"\"; new KurrentDbBuilder(image).Build(); var t = \"\"\"\"y\"\"\"\";")] public async Task Pattern_flags_a_port_docker_chooses(string code) => await Assert.That(PublishesOnDockersChoice(code)).IsTrue(); @@ -45,6 +51,7 @@ public async Task Pattern_flags_a_port_docker_chooses(string code) => [Arguments("new KurrentDbBuilder(image).WithPortBinding(ContainerHostPorts.Next(), 2113).Build()")] [Arguments("new KurrentDbBuilder(image).WithCreateParameterModifier(p => { p.Name = \"k\"; }).WithPortBinding(ContainerHostPorts.Next(), 2113).Build()")] [Arguments("// .WithPortBinding(2113, true)\n.WithPortBinding(ContainerHostPorts.Next(), Port)")] + [Arguments("new KurrentDbBuilder(ImageFactory().Build()).WithPortBinding(ContainerHostPorts.Next(), 2113).Build()")] public async Task Pattern_passes_a_port_picked_below_the_ephemeral_range(string code) => await Assert.That(PublishesOnDockersChoice(code)).IsFalse(); @@ -66,36 +73,36 @@ static bool PublishesOnDockersChoice(string source) { var code = SourceCode.WithoutCommentsOrStrings(source); return DockerChosenBinding().IsMatch(code) - || PublishingBuilder().Matches(code).Any(builder => !PickedBinding().IsMatch(ChainOf(code, builder))); + || PublishingBuilder().Matches(code).Any(builder => !BindsItsOwnPort(code, builder)); } - /// The code from a builder's construction to whichever comes first: its .Build(), the end of its - /// statement, or the next builder's construction. A binding added in a later statement does not count. - static string ChainOf(string code, Match builder) { + /// Whether the builder's own chain, from its construction to whichever comes first of its .Build(), + /// the end of its statement or the next builder's construction, carries a picked binding. Only a call chained + /// on the builder itself counts, outside every bracket opened after it: a binding or a .Build() inside an + /// argument or a lambda in the chain belongs to something else, and a binding added in a later statement does + /// not count. + static bool BindsItsOwnPort(string code, Match builder) { var start = builder.Index + builder.Length; + var next = PublishingBuilder().Match(code, start) is { Success: true } other ? other.Index : code.Length; - int[] ends = [ - Build().Match(code, start) is { Success: true } build ? build.Index : code.Length, - StatementEnd(code, start), - PublishingBuilder().Match(code, start) is { Success: true } next ? next.Index : code.Length - ]; - - return code[start..ends.Min()]; - } - - /// The first ; outside every bracket opened after , which lies inside the builder's - /// own constructor call, so a statement inside a lambda in the chain does not end it. - static int StatementEnd(string code, int start) { + // Counting from 1 inside the builder's constructor call. var depth = 1; - for (var i = start; i < code.Length; i++) + for (var i = start; i < next; i++) { switch (code[i]) { - case '(' or '[' or '{': depth++; break; - case ')' or ']' or '}': depth--; break; - case ';' when depth <= 0: return i; + case '(' or '[' or '{': depth++; continue; + case ')' or ']' or '}': depth--; continue; + case ';' when depth <= 0: return false; } - return code.Length; + if (depth != 0 || code[i] != '.') continue; + + if (Build().IsMatch(code[i..Math.Min(code.Length, i + 16)])) return false; + + if (PickedBinding().Match(code, i) is { Success: true } binding && binding.Index == i) return true; + } + + return false; } static string RepoRoot([CallerFilePath] string here = "") { diff --git a/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs index 3a60e03386..773b211ed0 100644 --- a/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs @@ -9,9 +9,15 @@ namespace Capacitor.Cli.Tests.Unit; /// call, matched with string contents blanked so a literal containing tag: cannot pass for the named argument. /// public partial class ContainerImageStringGuardTests { - [GeneratedRegex(@"new\s+\w*ContainerBuilder\s*\((?!\s*new\s+DockerImage\s*\()|\w*ContainerBuilder\s+\w+\s*=\s*new\s*\((?!\s*new\s+DockerImage\s*\()|\.WithImage\s*\((?!\s*new\s+DockerImage\s*\()|new\s+DockerImage\s*\((?![^()]*\btag\s*:)")] + [GeneratedRegex(@"new\s+(?:[\w.]+\.)?(?:\w*ContainerBuilder|KurrentDbBuilder|PostgreSqlBuilder)\s*\((?!\s*new\s+DockerImage\s*\()|(?:\w*ContainerBuilder|KurrentDbBuilder|PostgreSqlBuilder)\??\s+\w+\s*=\s*new\s*\((?!\s*new\s+DockerImage\s*\()|\.WithImage\s*\((?!\s*new\s+DockerImage\s*\()")] private static partial Regex ImageString(); + [GeneratedRegex(@"new\s+DockerImage\s*\(")] + private static partial Regex DockerImage(); + + [GeneratedRegex(@"\G\btag\s*:")] + private static partial Regex TagArgument(); + [Test] [Arguments("new ContainerBuilder(Image)")] [Arguments("new ContainerBuilder(\"ghcr.io/workos/emulate:0.15.0\")")] @@ -22,6 +28,10 @@ public partial class ContainerImageStringGuardTests { [Arguments("new DockerImage(\"tag:3\")")] [Arguments(".WithImage(\"alpine:3\")")] [Arguments("ContainerBuilder b = new(\"alpine:3\");")] + [Arguments("new KurrentDbBuilder(\"kurrentdb:26\")")] + [Arguments("new PostgreSqlBuilder(\"postgres:18\")")] + [Arguments("new DockerImage(Repository(\"tag: x\"))")] + [Arguments("var s = \"\"\"\"x\"\"\"\"; new ContainerBuilder(\"alpine:3\"); var t = \"\"\"\"y\"\"\"\";")] public async Task Pattern_matches_an_untyped_image(string code) => await Assert.That(UsesAnImageString(code)).IsTrue(); @@ -30,6 +40,8 @@ public async Task Pattern_matches_an_untyped_image(string code) => [Arguments(".WithImage(new DockerImage(\"alpine\", tag: \"3\"))")] [Arguments("new StringBuilder(\"text\")")] [Arguments("ContainerBuilder b = new(new DockerImage(\"alpine\", tag: \"3\"));")] + [Arguments("new DockerImage(GetRepository(), tag: GetTag())")] + [Arguments("new KurrentDbBuilder(new DockerImage(\"kurrentdb\", tag: \"26\"))")] public async Task Pattern_passes_a_typed_image(string code) => await Assert.That(UsesAnImageString(code)).IsFalse(); @@ -47,7 +59,30 @@ await Assert.That(offenders).IsEmpty() .Because("pass new DockerImage(repository, tag: …) so Testcontainers never runs its timed image-name regex on it"); } - static bool UsesAnImageString(string code) => ImageString().IsMatch(SourceCode.WithoutCommentsOrStrings(code)); + static bool UsesAnImageString(string source) { + var code = SourceCode.WithoutCommentsOrStrings(source); + + return ImageString().IsMatch(code) || DockerImage().Matches(code).Any(call => !NamesItsTag(code, call.Index + call.Length)); + } + + /// Whether the DockerImage call whose arguments start at passes tag: + /// as one of its own arguments, not inside a nested call's. + static bool NamesItsTag(string code, int start) { + var depth = 0; + + for (var i = start; i < code.Length; i++) { + switch (code[i]) { + case '(' or '[' or '{': depth++; break; + case ')' or ']' or '}' when depth == 0: return false; + case ')' or ']' or '}': depth--; break; + default: + if (depth == 0 && TagArgument().IsMatch(code[i..Math.Min(code.Length, i + 8)])) return true; + break; + } + } + + return false; + } static string RepoRoot([CallerFilePath] string here = "") { var dir = Path.GetDirectoryName(here); diff --git a/test/Capacitor.Cli.Tests.Unit/SourceCode.cs b/test/Capacitor.Cli.Tests.Unit/SourceCode.cs index 6eaa87c962..2588e83655 100644 --- a/test/Capacitor.Cli.Tests.Unit/SourceCode.cs +++ b/test/Capacitor.Cli.Tests.Unit/SourceCode.cs @@ -6,7 +6,7 @@ namespace Capacitor.Cli.Tests.Unit; /// blanked, so neither can spell the shape or hide its absence. One left-to-right scan takes whichever starts first, so a /// quote inside a comment or a char literal such as '"' never opens a string that swallows the code after it. public static partial class SourceCode { - [GeneratedRegex("//[^\\n]*|/\\*[\\s\\S]*?\\*/|'(?:\\\\u[0-9A-Fa-f]{4}|\\\\.|[^'\\\\\\n])'|\"\"\"[\\s\\S]*?\"\"\"|@\"(?:[^\"]|\"\")*\"|\"(?:[^\"\\\\\\n]|\\\\.)*\"")] + [GeneratedRegex("//[^\\n]*|/\\*[\\s\\S]*?\\*/|'(?:\\\\u[0-9A-Fa-f]{4}|\\\\.|[^'\\\\\\n])'|(?\"{3,})[\\s\\S]*?\\k(?!\")|@\"(?:[^\"]|\"\")*\"|\"(?:[^\"\\\\\\n]|\\\\.)*\"")] private static partial Regex Token(); public static string WithoutCommentsOrStrings(string code) => From 801d4644135fbd1eb0a169d824764e88d0c20c26 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:38:20 -0400 Subject: [PATCH 08/10] End a builder chain at the expression that holds it A nested builder in an argument no longer cuts the outer chain short, and a top-level comma ends it. Co-Authored-By: Claude Opus 5.5 --- .../ContainerHostPortGuardTests.cs | 18 ++++++++++-------- .../ContainerImageStringGuardTests.cs | 3 ++- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs index c5cc751373..a529d00166 100644 --- a/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs @@ -43,6 +43,7 @@ public partial class ContainerHostPortGuardTests { [Arguments("KurrentDbBuilder? b = new(image); b.Build()")] [Arguments("new KurrentDbBuilder(image).WithCreateParameterModifier(_ => Side(new ContainerBuilder(i).WithPortBinding(ContainerHostPorts.Next(), 80))).Build()")] [Arguments("var s = \"\"\"\"x\"\"\"\"; new KurrentDbBuilder(image).Build(); var t = \"\"\"\"y\"\"\"\";")] + [Arguments("(new KurrentDbBuilder(image), new ContainerBuilder(i).WithPortBinding(ContainerHostPorts.Next(), 80))")] public async Task Pattern_flags_a_port_docker_chooses(string code) => await Assert.That(PublishesOnDockersChoice(code)).IsTrue(); @@ -52,6 +53,7 @@ public async Task Pattern_flags_a_port_docker_chooses(string code) => [Arguments("new KurrentDbBuilder(image).WithCreateParameterModifier(p => { p.Name = \"k\"; }).WithPortBinding(ContainerHostPorts.Next(), 2113).Build()")] [Arguments("// .WithPortBinding(2113, true)\n.WithPortBinding(ContainerHostPorts.Next(), Port)")] [Arguments("new KurrentDbBuilder(ImageFactory().Build()).WithPortBinding(ContainerHostPorts.Next(), 2113).Build()")] + [Arguments("new KurrentDbBuilder(image).WithX(new PostgreSqlBuilder(p).WithPortBinding(ContainerHostPorts.Next(), 5432).Build()).WithPortBinding(ContainerHostPorts.Next(), 2113).Build()")] public async Task Pattern_passes_a_port_picked_below_the_ephemeral_range(string code) => await Assert.That(PublishesOnDockersChoice(code)).IsFalse(); @@ -76,23 +78,23 @@ static bool PublishesOnDockersChoice(string source) { || PublishingBuilder().Matches(code).Any(builder => !BindsItsOwnPort(code, builder)); } - /// Whether the builder's own chain, from its construction to whichever comes first of its .Build(), - /// the end of its statement or the next builder's construction, carries a picked binding. Only a call chained - /// on the builder itself counts, outside every bracket opened after it: a binding or a .Build() inside an - /// argument or a lambda in the chain belongs to something else, and a binding added in a later statement does - /// not count. + /// Whether the builder's own chain, from its construction to its .Build() or the end of the expression + /// holding it (a ;, a , or the bracket closing around it), carries a picked binding. Only a call chained + /// on the builder itself counts, outside every bracket opened after it: a binding, a .Build() or another + /// builder inside an argument or a lambda in the chain belongs to something else, and a binding added in a later + /// statement does not count. static bool BindsItsOwnPort(string code, Match builder) { var start = builder.Index + builder.Length; - var next = PublishingBuilder().Match(code, start) is { Success: true } other ? other.Index : code.Length; // Counting from 1 inside the builder's constructor call. var depth = 1; - for (var i = start; i < next; i++) { + for (var i = start; i < code.Length; i++) { switch (code[i]) { case '(' or '[' or '{': depth++; continue; + case ')' or ']' or '}' when depth == 0: return false; case ')' or ']' or '}': depth--; continue; - case ';' when depth <= 0: return false; + case ';' or ',' when depth == 0: return false; } if (depth != 0 || code[i] != '.') continue; diff --git a/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs index 773b211ed0..b0d72aec3d 100644 --- a/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs @@ -9,7 +9,7 @@ namespace Capacitor.Cli.Tests.Unit; /// call, matched with string contents blanked so a literal containing tag: cannot pass for the named argument. /// public partial class ContainerImageStringGuardTests { - [GeneratedRegex(@"new\s+(?:[\w.]+\.)?(?:\w*ContainerBuilder|KurrentDbBuilder|PostgreSqlBuilder)\s*\((?!\s*new\s+DockerImage\s*\()|(?:\w*ContainerBuilder|KurrentDbBuilder|PostgreSqlBuilder)\??\s+\w+\s*=\s*new\s*\((?!\s*new\s+DockerImage\s*\()|\.WithImage\s*\((?!\s*new\s+DockerImage\s*\()")] + [GeneratedRegex(@"new\s+(?:[\w.]+\.)?(?:\w*ContainerBuilder|KurrentDbBuilder|PostgreSqlBuilder)\s*\((?!\s*new\s+DockerImage\s*\()|(?:[\w.]+\.)?(?:\w*ContainerBuilder|KurrentDbBuilder|PostgreSqlBuilder)\??\s+\w+\s*=\s*new\s*\((?!\s*new\s+DockerImage\s*\()|\.WithImage\s*\((?!\s*new\s+DockerImage\s*\()")] private static partial Regex ImageString(); [GeneratedRegex(@"new\s+DockerImage\s*\(")] @@ -32,6 +32,7 @@ public partial class ContainerImageStringGuardTests { [Arguments("new PostgreSqlBuilder(\"postgres:18\")")] [Arguments("new DockerImage(Repository(\"tag: x\"))")] [Arguments("var s = \"\"\"\"x\"\"\"\"; new ContainerBuilder(\"alpine:3\"); var t = \"\"\"\"y\"\"\"\";")] + [Arguments("Testcontainers.KurrentDb.KurrentDbBuilder b = new(\"kurrentdb:26\");")] public async Task Pattern_matches_an_untyped_image(string code) => await Assert.That(UsesAnImageString(code)).IsTrue(); From 420141f151547189e572565616b37c5c06ae1c79 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:55:07 -0400 Subject: [PATCH 09/10] Keep a conditional's other branch out of a builder's chain Co-Authored-By: Claude Opus 5.5 --- .../ContainerHostPortGuardTests.cs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs index a529d00166..1edd6e7382 100644 --- a/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs @@ -18,6 +18,9 @@ public partial class ContainerHostPortGuardTests { [GeneratedRegex(@"new\s+(?:[\w.]+\.)?(?:KurrentDb|PostgreSql)Builder\s*\(|(?:[\w.]+\.)?(?:KurrentDb|PostgreSql)Builder\??\s+\w+\s*=\s*new\s*\(")] private static partial Regex PublishingBuilder(); + [GeneratedRegex(@"\G(?:new\s+(?:[\w.]+\.)?(?:KurrentDb|PostgreSql)Builder\s*\(|(?:[\w.]+\.)?(?:KurrentDb|PostgreSql)Builder\??\s+\w+\s*=\s*new\s*\()")] + private static partial Regex PublishingBuilderHere(); + [GeneratedRegex(@"\.WithPortBinding\s*\(\s*ContainerHostPorts\.Next\s*\(\s*\)\s*,(?!\s*(?:true\b|assignRandomHostPort\s*:))")] private static partial Regex PickedBinding(); @@ -44,6 +47,7 @@ public partial class ContainerHostPortGuardTests { [Arguments("new KurrentDbBuilder(image).WithCreateParameterModifier(_ => Side(new ContainerBuilder(i).WithPortBinding(ContainerHostPorts.Next(), 80))).Build()")] [Arguments("var s = \"\"\"\"x\"\"\"\"; new KurrentDbBuilder(image).Build(); var t = \"\"\"\"y\"\"\"\";")] [Arguments("(new KurrentDbBuilder(image), new ContainerBuilder(i).WithPortBinding(ContainerHostPorts.Next(), 80))")] + [Arguments("var b = flag ? new KurrentDbBuilder(image) : new KurrentDbBuilder(image).WithPortBinding(ContainerHostPorts.Next(), 2113);")] public async Task Pattern_flags_a_port_docker_chooses(string code) => await Assert.That(PublishesOnDockersChoice(code)).IsTrue(); @@ -79,10 +83,10 @@ static bool PublishesOnDockersChoice(string source) { } /// Whether the builder's own chain, from its construction to its .Build() or the end of the expression - /// holding it (a ;, a , or the bracket closing around it), carries a picked binding. Only a call chained - /// on the builder itself counts, outside every bracket opened after it: a binding, a .Build() or another - /// builder inside an argument or a lambda in the chain belongs to something else, and a binding added in a later - /// statement does not count. + /// holding it (a ;, a ,, a conditional's ? or :, a sibling builder, or the bracket closing + /// around it), carries a picked binding. Only a call chained on the builder itself counts, outside every bracket + /// opened after it: a binding, a .Build() or another builder inside an argument or a lambda in the chain + /// belongs to something else, and a binding added in a later statement does not count. static bool BindsItsOwnPort(string code, Match builder) { var start = builder.Index + builder.Length; @@ -94,10 +98,16 @@ static bool BindsItsOwnPort(string code, Match builder) { case '(' or '[' or '{': depth++; continue; case ')' or ']' or '}' when depth == 0: return false; case ')' or ']' or '}': depth--; continue; - case ';' or ',' when depth == 0: return false; + case ';' or ',' or ':' when depth == 0: return false; + case '?' when depth == 0 && (i + 1 >= code.Length || code[i + 1] != '.'): return false; } - if (depth != 0 || code[i] != '.') continue; + if (depth != 0) continue; + + // A sibling builder at the same depth, such as the other branch of a conditional, has a chain of its own. + if (PublishingBuilderHere().IsMatch(code[i..Math.Min(code.Length, i + 200)])) return false; + + if (code[i] != '.') continue; if (Build().IsMatch(code[i..Math.Min(code.Length, i + 16)])) return false; From 3ebbe3444d7b96c20b95fbb950befff24bde9047 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:00:03 -0400 Subject: [PATCH 10/10] Pick again until a port test holds its port itself Co-Authored-By: Claude Opus 5.5 --- .../ContainerHostPortsTests.cs | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs b/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs index 26c23d266c..bb63950c1a 100644 --- a/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs +++ b/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs @@ -74,20 +74,23 @@ public async Task The_emulator_and_the_resource_reaper_publish_below_the_ephemer await Assert.That(reaperPort).IsLessThan(EphemeralFloor); } - /// Holds the port one past the latest pick, which is the one the next pick lands on. + /// Holds the port one past the latest pick, which is the one the next pick lands on. A candidate something + /// else already holds could be let go mid-test, so the test picks again until it holds one itself. static (int Port, TcpListener Holder) HoldTheNextPort(bool ipv6Only = false) { - var port = ContainerHostPorts.Next() + 1; - if (port > ContainerHostPorts.Last) port = ContainerHostPorts.First; + for (var attempt = 0; ; attempt++) { + var port = ContainerHostPorts.Next() + 1; + if (port > ContainerHostPorts.Last) port = ContainerHostPorts.First; - var holder = new TcpListener(ipv6Only ? IPAddress.IPv6Any : IPAddress.Any, port); - if (ipv6Only) holder.Server.DualMode = false; + var holder = new TcpListener(ipv6Only ? IPAddress.IPv6Any : IPAddress.Any, port); + if (ipv6Only) holder.Server.DualMode = false; - try { - holder.Start(); - } catch (SocketException) { - // something else already holds it, which serves as well - } + try { + holder.Start(); - return (port, holder); + return (port, holder); + } catch (SocketException) when (attempt < 20) { + holder.Dispose(); + } + } } }