diff --git a/test/Capacitor.Cli.Tests.Integration/ContainerHostPorts.cs b/test/Capacitor.Cli.Tests.Integration/ContainerHostPorts.cs
new file mode 100644
index 000000000..d3d0afa3a
--- /dev/null
+++ b/test/Capacitor.Cli.Tests.Integration/ContainerHostPorts.cs
@@ -0,0 +1,99 @@
+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;
+ }
+ }
+ }
+
+ /// "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("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 {
+ 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
new file mode 100644
index 000000000..bb63950c1
--- /dev/null
+++ b/test/Capacitor.Cli.Tests.Integration/ContainerHostPortsTests.cs
@@ -0,0 +1,96 @@
+using System.Globalization;
+using System.Net;
+using System.Net.Sockets;
+using Docker.DotNet;
+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, holder) = HoldTheNextPort();
+ using var _ = holder;
+
+ 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();
+ }
+
+ /// 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]
+ [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);
+
+ 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(reaperPort).IsLessThan(EphemeralFloor);
+ }
+
+ /// 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) {
+ 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;
+
+ try {
+ holder.Start();
+
+ return (port, holder);
+ } catch (SocketException) when (attempt < 20) {
+ holder.Dispose();
+ }
+ }
+ }
+}
diff --git a/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs b/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs
index e3991f209..fee862cde 100644
--- a/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs
+++ b/test/Capacitor.Cli.Tests.Integration/WorkOSEmulator.cs
@@ -1,8 +1,8 @@
using System.Text;
-using Capacitor.Cli.Core.Auth;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Configurations;
using DotNet.Testcontainers.Containers;
+using DotNet.Testcontainers.Images;
namespace Capacitor.Cli.Tests.Integration;
@@ -15,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";
@@ -68,31 +67,22 @@ 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)
+ // 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)
.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
- /// 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
new file mode 100644
index 000000000..b2415c1a7
--- /dev/null
+++ b/test/Capacitor.Cli.Tests.Unit/ContainerHostPortGuardTests.cs
@@ -0,0 +1,128 @@
+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. The KurrentDB and
+/// 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 {
+ // 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+(?:[\w.]+\.)?(?:KurrentDb|PostgreSql)Builder\s*\(|(?:[\w.]+\.)?(?:KurrentDb|PostgreSql)Builder\??\s+\w+\s*=\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*(?:[^;=]*?[?:]\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();
+
+ [GeneratedRegex(@"\G\.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()")]
+ [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()")]
+ [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\"\"\"\";")]
+ [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);")]
+ [Arguments("KurrentDbBuilder b = flag ? new(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();
+
+ [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)")]
+ [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();
+
+ [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 => PublishesOnDockersChoice(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 bool PublishesOnDockersChoice(string source) {
+ var code = SourceCode.WithoutCommentsOrStrings(source);
+
+ return DockerChosenBinding().IsMatch(code)
+ || PublishingBuilder().Matches(code).Any(builder => !BindsItsOwnPort(code, builder));
+ }
+
+ /// Whether the builder's own chain, from its construction to its .Build() or the end of the expression
+ /// 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;
+
+ // Counting from 1 inside the builder's constructor call.
+ var depth = 1;
+
+ 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 ';' or ',' or ':' when depth == 0: return false;
+ case '?' when depth == 0 && (i + 1 >= code.Length || code[i + 1] != '.'): return false;
+ }
+
+ 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;
+
+ if (PickedBinding().Match(code, i) is { Success: true } binding && binding.Index == i) return true;
+ }
+
+ return false;
+ }
+
+ 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}");
+ }
+}
diff --git a/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs b/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs
new file mode 100644
index 000000000..b0d72aec3
--- /dev/null
+++ b/test/Capacitor.Cli.Tests.Unit/ContainerImageStringGuardTests.cs
@@ -0,0 +1,95 @@
+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.]+\.)?(?:\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*\(")]
+ 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\")")]
+ [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\")")]
+ [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\"\"\"\";")]
+ [Arguments("Testcontainers.KurrentDb.KurrentDbBuilder b = new(\"kurrentdb:26\");")]
+ 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\")")]
+ [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();
+
+ [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 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);
+ 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}");
+ }
+}
diff --git a/test/Capacitor.Cli.Tests.Unit/SourceCode.cs b/test/Capacitor.Cli.Tests.Unit/SourceCode.cs
new file mode 100644
index 000000000..2588e8365
--- /dev/null
+++ b/test/Capacitor.Cli.Tests.Unit/SourceCode.cs
@@ -0,0 +1,18 @@
+using System.Text.RegularExpressions;
+
+namespace Capacitor.Cli.Tests.Unit;
+
+/// 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("//[^\\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) =>
+ Token().Replace(code, m => m.Value[0] switch {
+ '/' => " ",
+ '\'' => "' '",
+ _ => "\"\""
+ });
+}