From 8b490eb26c51430a75613cc4469218afc499d8a9 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:15:12 +0200 Subject: [PATCH 01/96] chore: add .NET 10 solution with core, GUI host, executable and test projects --- Directory.Build.props | 7 +++++++ SqlRunboard.slnx | 11 +++++++++++ global.json | 9 +++++++++ src/SqlRunboard.Core/SqlRunboard.Core.csproj | 2 ++ src/SqlRunboard.Gui/SqlRunboard.Gui.csproj | 7 +++++++ src/sqlrunboard/sqlrunboard.csproj | 10 ++++++++++ .../SqlRunboard.Core.Tests.csproj | 10 ++++++++++ .../SqlRunboard.Gui.Tests.csproj | 13 +++++++++++++ 8 files changed, 69 insertions(+) create mode 100644 Directory.Build.props create mode 100644 SqlRunboard.slnx create mode 100644 global.json create mode 100644 src/SqlRunboard.Core/SqlRunboard.Core.csproj create mode 100644 src/SqlRunboard.Gui/SqlRunboard.Gui.csproj create mode 100644 src/sqlrunboard/sqlrunboard.csproj create mode 100644 tests/SqlRunboard.Core.Tests/SqlRunboard.Core.Tests.csproj create mode 100644 tests/SqlRunboard.Gui.Tests/SqlRunboard.Gui.Tests.csproj diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..e3aac5e --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/SqlRunboard.slnx b/SqlRunboard.slnx new file mode 100644 index 0000000..94e5ab7 --- /dev/null +++ b/SqlRunboard.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/global.json b/global.json new file mode 100644 index 0000000..1d364c6 --- /dev/null +++ b/global.json @@ -0,0 +1,9 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/src/SqlRunboard.Core/SqlRunboard.Core.csproj b/src/SqlRunboard.Core/SqlRunboard.Core.csproj new file mode 100644 index 0000000..35e3d84 --- /dev/null +++ b/src/SqlRunboard.Core/SqlRunboard.Core.csproj @@ -0,0 +1,2 @@ + + diff --git a/src/SqlRunboard.Gui/SqlRunboard.Gui.csproj b/src/SqlRunboard.Gui/SqlRunboard.Gui.csproj new file mode 100644 index 0000000..b3dbb85 --- /dev/null +++ b/src/SqlRunboard.Gui/SqlRunboard.Gui.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/sqlrunboard/sqlrunboard.csproj b/src/sqlrunboard/sqlrunboard.csproj new file mode 100644 index 0000000..95d5273 --- /dev/null +++ b/src/sqlrunboard/sqlrunboard.csproj @@ -0,0 +1,10 @@ + + + Exe + sqlrunboard + SqlRunboard + + + + + diff --git a/tests/SqlRunboard.Core.Tests/SqlRunboard.Core.Tests.csproj b/tests/SqlRunboard.Core.Tests/SqlRunboard.Core.Tests.csproj new file mode 100644 index 0000000..a76dd91 --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/SqlRunboard.Core.Tests.csproj @@ -0,0 +1,10 @@ + + + Exe + + + + + + + diff --git a/tests/SqlRunboard.Gui.Tests/SqlRunboard.Gui.Tests.csproj b/tests/SqlRunboard.Gui.Tests/SqlRunboard.Gui.Tests.csproj new file mode 100644 index 0000000..dac41d7 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/SqlRunboard.Gui.Tests.csproj @@ -0,0 +1,13 @@ + + + Exe + + + + + + + + + + From 84750f40a3d095e8812872a57577a8eb87614c6e Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:15:14 +0200 Subject: [PATCH 02/96] feat(core): add SQL executor seam, fake executor and fake clock for tests --- src/SqlRunboard.Core/Batch.cs | 34 ++++++++++ src/SqlRunboard.Core/FakeSqlExecutor.cs | 33 +++++++++ src/SqlRunboard.Core/SqlExecutor.cs | 22 ++++++ .../FakeSqlExecutorTests.cs | 60 ++++++++++++++++ tests/SqlRunboard.Core.Tests/FakeTime.cs | 68 +++++++++++++++++++ 5 files changed, 217 insertions(+) create mode 100644 src/SqlRunboard.Core/Batch.cs create mode 100644 src/SqlRunboard.Core/FakeSqlExecutor.cs create mode 100644 src/SqlRunboard.Core/SqlExecutor.cs create mode 100644 tests/SqlRunboard.Core.Tests/FakeSqlExecutorTests.cs create mode 100644 tests/SqlRunboard.Core.Tests/FakeTime.cs diff --git a/src/SqlRunboard.Core/Batch.cs b/src/SqlRunboard.Core/Batch.cs new file mode 100644 index 0000000..24c40f9 --- /dev/null +++ b/src/SqlRunboard.Core/Batch.cs @@ -0,0 +1,34 @@ +namespace SqlRunboard.Core; + +public enum Auth { Integrated, SqlServer, EntraInteractive, EntraDefault } + +public enum Encrypt { Mandatory, Optional, Strict } + +/// The target block of the batch file. See docs/SPEC.md "Batch". +public sealed record BatchTarget +{ + public string? Server { get; init; } + public string? Database { get; init; } + public Auth Auth { get; init; } = Auth.Integrated; + public string? User { get; init; } + public string? PasswordVariable { get; init; } + public Encrypt Encrypt { get; init; } = Encrypt.Mandatory; + public bool TrustServerCertificate { get; init; } + public string ExtraConnectionString { get; init; } = ""; +} + +/// The batch settings, with the defaults from docs/SPEC.md "Batch". +public sealed record BatchSettings +{ + public string? Folder { get; init; } + public string Filters { get; init; } = "*.sql"; + public bool Recurse { get; init; } + public int ParallelLimit { get; init; } = 4; + public bool StopOnFirstFailure { get; init; } + public bool ConfirmBeforeRun { get; init; } + public int? TimeoutSeconds { get; init; } + public BatchTarget Target { get; init; } = new(); +} + +/// One entry in the side list. File is null for an unsaved batch. +public sealed record Batch(int Id, string Name, string? File, bool Dirty, BatchSettings Settings); diff --git a/src/SqlRunboard.Core/FakeSqlExecutor.cs b/src/SqlRunboard.Core/FakeSqlExecutor.cs new file mode 100644 index 0000000..7e6d272 --- /dev/null +++ b/src/SqlRunboard.Core/FakeSqlExecutor.cs @@ -0,0 +1,33 @@ +using System.Collections.Concurrent; + +namespace SqlRunboard.Core; + +/// What the fake does for one script. The default succeeds at once. +public sealed record FakeScript +{ + public TimeSpan Duration { get; init; } + public ScriptStatus Status { get; init; } = ScriptStatus.Succeeded; + public long? Rows { get; init; } + public string? Error { get; init; } + public IReadOnlyList Messages { get; init; } = []; +} + +/// +/// Plays a for each script name, and records each call. +/// The duration waits on the , so a fake clock controls when a script ends. +/// +public sealed class FakeSqlExecutor(TimeProvider time) : ISqlExecutor +{ + public ConcurrentDictionary Scripts { get; } = new(); + public ConcurrentQueue Calls { get; } = new(); + + public async Task ExecuteAsync(SqlExecution execution, Action onMessage, CancellationToken ct) + { + Calls.Enqueue(execution); + var script = Scripts.GetValueOrDefault(execution.ScriptName) ?? new FakeScript(); + foreach (var m in script.Messages) onMessage(m); + await Task.Delay(script.Duration, time, ct); + if (script.Error is not null) onMessage(new SqlMessage(SqlMessageKind.Error, script.Error)); + return new SqlResult(script.Status, script.Rows, script.Error); + } +} diff --git a/src/SqlRunboard.Core/SqlExecutor.cs b/src/SqlRunboard.Core/SqlExecutor.cs new file mode 100644 index 0000000..41e1aca --- /dev/null +++ b/src/SqlRunboard.Core/SqlExecutor.cs @@ -0,0 +1,22 @@ +namespace SqlRunboard.Core; + +public enum ScriptStatus { Queued, Running, Succeeded, Failed, Cancelled } + +public enum SqlMessageKind { Info, RowCount, Error } + +public sealed record SqlMessage(SqlMessageKind Kind, string Text); + +/// The target of the batch plus the password from the page. The page never gets this. +public sealed record SqlConnectionSettings(BatchTarget Target, string? Password); + +/// One attempt of one script: the commands (the GO parts) to send on one new connection. +public sealed record SqlExecution(string ScriptName, IReadOnlyList Commands, SqlConnectionSettings Connection); + +/// Status is Succeeded or Failed. A cancel throws OperationCanceledException. +public sealed record SqlResult(ScriptStatus Status, long? Rows, string? Error); + +/// The seam to SQL Server. The real adapter comes with ticket #7; tests use . +public interface ISqlExecutor +{ + Task ExecuteAsync(SqlExecution execution, Action onMessage, CancellationToken ct); +} diff --git a/tests/SqlRunboard.Core.Tests/FakeSqlExecutorTests.cs b/tests/SqlRunboard.Core.Tests/FakeSqlExecutorTests.cs new file mode 100644 index 0000000..4311aac --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/FakeSqlExecutorTests.cs @@ -0,0 +1,60 @@ +using SqlRunboard.Core; + +public class FakeSqlExecutorTests +{ + static readonly SqlConnectionSettings Connection = new(new BatchTarget { Server = "sql01", Database = "Sales" }, Password: "secret"); + + [Fact] + public async Task Script_runs_for_its_duration_on_the_clock_then_gives_its_result() + { + var time = new FakeTime(); + var executor = new FakeSqlExecutor(time); + executor.Scripts["005_fk.sql"] = new FakeScript + { + Duration = TimeSpan.FromSeconds(30), + Status = ScriptStatus.Failed, + Rows = 42, + Error = "line 214 (part 3): FK conflict", + Messages = [new SqlMessage(SqlMessageKind.Info, "Step 1 of 3 done")], + }; + var messages = new List(); + + var run = executor.ExecuteAsync(new SqlExecution("005_fk.sql", ["SELECT 1", "SELECT 2"], Connection), messages.Add, CancellationToken.None); + time.Advance(TimeSpan.FromSeconds(29)); + Assert.False(run.IsCompleted); + time.Advance(TimeSpan.FromSeconds(1)); + var result = await run; + + Assert.Equal(new SqlResult(ScriptStatus.Failed, 42, "line 214 (part 3): FK conflict"), result); + Assert.Equal( + [new SqlMessage(SqlMessageKind.Info, "Step 1 of 3 done"), new SqlMessage(SqlMessageKind.Error, "line 214 (part 3): FK conflict")], + messages); + var call = Assert.Single(executor.Calls); + Assert.Equal(["SELECT 1", "SELECT 2"], call.Commands); + Assert.Equal("sql01", call.Connection.Target.Server); + Assert.Equal("secret", call.Connection.Password); + } + + [Fact] + public async Task Unknown_script_succeeds_at_once() + { + var executor = new FakeSqlExecutor(new FakeTime()); + + var result = await executor.ExecuteAsync(new SqlExecution("x.sql", ["SELECT 1"], Connection), _ => { }, CancellationToken.None); + + Assert.Equal(new SqlResult(ScriptStatus.Succeeded, null, null), result); + } + + [Fact] + public async Task Cancel_stops_a_running_script() + { + var executor = new FakeSqlExecutor(new FakeTime()); + executor.Scripts["slow.sql"] = new FakeScript { Duration = TimeSpan.FromMinutes(5) }; + using var cts = new CancellationTokenSource(); + + var run = executor.ExecuteAsync(new SqlExecution("slow.sql", ["SELECT 1"], Connection), _ => { }, cts.Token); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => run); + } +} diff --git a/tests/SqlRunboard.Core.Tests/FakeTime.cs b/tests/SqlRunboard.Core.Tests/FakeTime.cs new file mode 100644 index 0000000..15d752d --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/FakeTime.cs @@ -0,0 +1,68 @@ +/// A manual clock for tests. Time moves only when you call . Timers (Task.Delay, CancelAfter) fire in due order. +public sealed class FakeTime(DateTimeOffset start) : TimeProvider +{ + readonly Lock gate = new(); + readonly List timers = []; + DateTimeOffset now = start; + + public FakeTime() : this(new DateTimeOffset(2026, 10, 9, 12, 0, 0, TimeSpan.Zero)) { } + + public override DateTimeOffset GetUtcNow() { lock (gate) return now; } + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + public override long GetTimestamp() => GetUtcNow().UtcTicks; + + public void Advance(TimeSpan delta) + { + DateTimeOffset end; + lock (gate) end = now + delta; + while (true) + { + FakeTimer? next; + lock (gate) + { + next = timers.Where(t => t.Due <= end).MinBy(t => t.Due); + if (next is null) { now = end; return; } + now = next.Due; + next.Reschedule(now); + } + next.Fire(); // outside the lock: the callback can create or change timers + } + } + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var t = new FakeTimer(this, callback, state); + t.Change(dueTime, period); + return t; + } + + sealed class FakeTimer(FakeTime clock, TimerCallback callback, object? state) : ITimer + { + TimeSpan period; + public DateTimeOffset Due { get; private set; } + + public bool Change(TimeSpan dueTime, TimeSpan period) + { + lock (clock.gate) + { + clock.timers.Remove(this); + this.period = period; + if (dueTime == Timeout.InfiniteTimeSpan) return true; + Due = clock.now + dueTime; + clock.timers.Add(this); + } + return true; + } + + // Called under the clock lock when the timer fires. + public void Reschedule(DateTimeOffset now) + { + if (period == Timeout.InfiniteTimeSpan || period <= TimeSpan.Zero) clock.timers.Remove(this); + else Due = now + period; + } + + public void Fire() => callback(state); + public void Dispose() { lock (clock.gate) clock.timers.Remove(this); } + public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; } + } +} From 4f28b32a2b2c812a570a7d57cabeb7e038512308 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:16:20 +0200 Subject: [PATCH 03/96] feat(gui): host on 127.0.0.1 with a random port and token-to-cookie auth --- src/SqlRunboard.Gui/GuiHost.cs | 81 ++++++++++++++++++++++++ src/SqlRunboard.Gui/wwwroot/index.html | 5 ++ tests/SqlRunboard.Gui.Tests/HostTests.cs | 61 ++++++++++++++++++ tests/SqlRunboard.Gui.Tests/TestApp.cs | 35 ++++++++++ 4 files changed, 182 insertions(+) create mode 100644 src/SqlRunboard.Gui/GuiHost.cs create mode 100644 src/SqlRunboard.Gui/wwwroot/index.html create mode 100644 tests/SqlRunboard.Gui.Tests/HostTests.cs create mode 100644 tests/SqlRunboard.Gui.Tests/TestApp.cs diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs new file mode 100644 index 0000000..4e518f5 --- /dev/null +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -0,0 +1,81 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SqlRunboard.Core; + +namespace SqlRunboard.Gui; + +/// Settings, lock file and logs of unsaved batches. Created if missing. +public sealed record GuiHostOptions(string AppDataFolder, ISqlExecutor Executor, TimeProvider Time); + +/// The local web server: Kestrel on 127.0.0.1, a random port, a random token. See docs/SPEC.md "GUI host". +public sealed class GuiHost : IAsyncDisposable +{ + public const string CookieName = "sqlrunboard"; + + readonly WebApplication app; + + GuiHost(WebApplication app, Uri baseUrl, string token) { this.app = app; BaseUrl = baseUrl; Token = token; } + + public Uri BaseUrl { get; } + public string Token { get; } + /// The URL to open. The first request changes the token into a cookie. + public Uri StartUrl => new(BaseUrl, "/?token=" + Token); + + public static async Task StartAsync(GuiHostOptions options) + { + Directory.CreateDirectory(options.AppDataFolder); + var token = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32)); + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseKestrel(k => k.Listen(IPAddress.Loopback, 0)); + builder.Logging.SetMinimumLevel(LogLevel.Warning); + builder.Services.AddSingleton(options); + builder.Services.AddSingleton(options.Executor); + builder.Services.AddSingleton(options.Time); + + var app = builder.Build(); + app.Use(RequireToken(token)); + var files = new EmbeddedFileProvider(typeof(GuiHost).Assembly, "SqlRunboard.Gui.wwwroot"); + app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files }); + app.UseStaticFiles(new StaticFileOptions { FileProvider = files }); + + await app.StartAsync(); + return new GuiHost(app, new Uri(app.Urls.First()), token); + } + + /// The token in the query string becomes a cookie, then a redirect drops it from the URL. All other requests need the cookie. + static Func RequireToken(string token) + { + var expected = Encoding.UTF8.GetBytes(token); + bool Valid(string? s) => s is not null && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(s), expected); + + return async (ctx, next) => + { + if (ctx.Request.Query.TryGetValue("token", out var q)) + { + if (!Valid(q)) { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } + ctx.Response.Cookies.Append(CookieName, token, new CookieOptions { HttpOnly = true, SameSite = SameSiteMode.Strict, Path = "/" }); + ctx.Response.Redirect(ctx.Request.PathBase + ctx.Request.Path); + return; + } + if (!Valid(ctx.Request.Cookies[CookieName])) { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } + await next(ctx); + }; + } + + public Task WaitForShutdownAsync() => app.WaitForShutdownAsync(); + + public async ValueTask DisposeAsync() + { + await app.StopAsync(); + await app.DisposeAsync(); + } +} diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html new file mode 100644 index 0000000..547c082 --- /dev/null +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -0,0 +1,5 @@ + + +SQLRunboard + + diff --git a/tests/SqlRunboard.Gui.Tests/HostTests.cs b/tests/SqlRunboard.Gui.Tests/HostTests.cs new file mode 100644 index 0000000..1f6e363 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/HostTests.cs @@ -0,0 +1,61 @@ +using System.Net; + +public class HostTests +{ + static readonly CancellationToken Ct = TestContext.Current.CancellationToken; + + [Fact] + public async Task Listens_on_loopback_on_a_random_port() + { + await using var app = await TestApp.StartAsync(); + + Assert.Equal("127.0.0.1", app.Host.BaseUrl.Host); + Assert.NotEqual(0, app.Host.BaseUrl.Port); + Assert.StartsWith(app.Host.BaseUrl.ToString(), app.Host.StartUrl.ToString()); + } + + [Fact] + public async Task Start_url_token_becomes_a_strict_HttpOnly_cookie_and_the_redirect_drops_the_token() + { + await using var app = await TestApp.StartAsync(); + using var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false }); + + var res = await http.GetAsync(app.Host.StartUrl, Ct); + + Assert.Equal(HttpStatusCode.Redirect, res.StatusCode); + Assert.Equal("/", res.Headers.Location?.OriginalString); + var cookie = Assert.Single(res.Headers.GetValues("Set-Cookie")); + Assert.StartsWith($"sqlrunboard={app.Host.Token};", cookie); + Assert.Contains("httponly", cookie, StringComparison.OrdinalIgnoreCase); + Assert.Contains("samesite=strict", cookie, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task The_cookie_opens_the_page() + { + await using var app = await TestApp.StartAsync(); + + var res = await app.Client().GetAsync("/", Ct); + + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + Assert.Equal("text/html", res.Content.Headers.ContentType?.MediaType); + } + + [Theory] + [InlineData("/")] + [InlineData("/?token=wrong")] + [InlineData("/index.html")] + [InlineData("/events")] + [InlineData("/api/batches")] + public async Task A_request_without_a_valid_token_or_cookie_is_rejected(string path) + { + await using var app = await TestApp.StartAsync(); + var cookies = new CookieContainer(); + cookies.Add(app.Host.BaseUrl, new Cookie("sqlrunboard", "wrong")); + using var withBadCookie = new HttpClient(new HttpClientHandler { CookieContainer = cookies }) { BaseAddress = app.Host.BaseUrl }; + using var anonymous = new HttpClient { BaseAddress = app.Host.BaseUrl }; + + Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync(path, Ct)).StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, (await withBadCookie.GetAsync(path, Ct)).StatusCode); + } +} diff --git a/tests/SqlRunboard.Gui.Tests/TestApp.cs b/tests/SqlRunboard.Gui.Tests/TestApp.cs new file mode 100644 index 0000000..8c95477 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/TestApp.cs @@ -0,0 +1,35 @@ +using System.Net; +using SqlRunboard.Core; +using SqlRunboard.Gui; + +/// The real server with the fake executor, the fake clock and a temporary app data folder. +public sealed class TestApp : IAsyncDisposable +{ + public required GuiHost Host { get; init; } + public required FakeSqlExecutor Executor { get; init; } + public required FakeTime Time { get; init; } + public required string AppData { get; init; } + + public static async Task StartAsync() + { + var appData = Directory.CreateTempSubdirectory("sqlrunboard-test-").FullName; + var time = new FakeTime(); + var executor = new FakeSqlExecutor(time); + var host = await GuiHost.StartAsync(new GuiHostOptions(appData, executor, time)); + return new TestApp { Host = host, Executor = executor, Time = time, AppData = appData }; + } + + /// An HTTP client that has the auth cookie, as the page has after the start URL. + public HttpClient Client() + { + var cookies = new CookieContainer(); + cookies.Add(Host.BaseUrl, new Cookie(GuiHost.CookieName, Host.Token)); + return new HttpClient(new HttpClientHandler { CookieContainer = cookies }) { BaseAddress = Host.BaseUrl }; + } + + public async ValueTask DisposeAsync() + { + await Host.DisposeAsync(); + Directory.Delete(AppData, recursive: true); + } +} From d46dfedcb208e5ccc67e61902c82e9ab658f85d6 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:17:20 +0200 Subject: [PATCH 04/96] feat(gui): stream the batch list over SSE and add New batch as a JSON POST --- src/SqlRunboard.Core/BatchList.cs | 53 ++++++++++++++++++++++ src/SqlRunboard.Gui/GuiHost.cs | 19 ++++++++ tests/SqlRunboard.Gui.Tests/EventsTests.cs | 40 ++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 src/SqlRunboard.Core/BatchList.cs create mode 100644 tests/SqlRunboard.Gui.Tests/EventsTests.cs diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs new file mode 100644 index 0000000..353661a --- /dev/null +++ b/src/SqlRunboard.Core/BatchList.cs @@ -0,0 +1,53 @@ +using System.Threading.Channels; + +namespace SqlRunboard.Core; + +/// A change for the page. Kind is the SSE event type; Data is the JSON payload. +public sealed record Change(string Kind, object Data); + +/// +/// The side list: all batches, in the order you added them. The server keeps this state; the page shows it. +/// Each subscriber gets the full state, then every change after it. +/// +public sealed class BatchList +{ + readonly Lock gate = new(); + readonly List batches = []; + readonly List> subscribers = []; + int lastId, lastUntitled; + + public Batch NewBatch() + { + lock (gate) + { + var batch = new Batch(++lastId, $"Untitled {++lastUntitled}", File: null, Dirty: true, new BatchSettings()); + batches.Add(batch); + Publish(new Change("batch", batch)); + return batch; + } + } + + /// The snapshot and the subscription are taken under one lock, so no change is lost between them. + public Subscription Subscribe() + { + var channel = Channel.CreateUnbounded(); + lock (gate) + { + subscribers.Add(channel); + return new Subscription([.. batches], channel.Reader, () => { lock (gate) subscribers.Remove(channel); }); + } + } + + // Call under the lock, so each subscriber sees the changes in order. + void Publish(Change change) + { + foreach (var s in subscribers) s.Writer.TryWrite(change); + } + + public sealed class Subscription(IReadOnlyList snapshot, ChannelReader changes, Action unsubscribe) : IDisposable + { + public IReadOnlyList Snapshot => snapshot; + public ChannelReader Changes => changes; + public void Dispose() => unsubscribe(); + } +} diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 4e518f5..9789ed7 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -1,6 +1,9 @@ using System.Net; +using System.Net.ServerSentEvents; +using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -40,6 +43,8 @@ public static async Task StartAsync(GuiHostOptions options) builder.Services.AddSingleton(options); builder.Services.AddSingleton(options.Executor); builder.Services.AddSingleton(options.Time); + builder.Services.AddSingleton(); + builder.Services.ConfigureHttpJsonOptions(j => j.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); var app = builder.Build(); app.Use(RequireToken(token)); @@ -47,10 +52,24 @@ public static async Task StartAsync(GuiHostOptions options) app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files }); app.UseStaticFiles(new StaticFileOptions { FileProvider = files }); + // Server to page: SSE. Page to server: POST with a JSON body, one route per command. + app.MapGet("/events", (BatchList list, CancellationToken ct) => TypedResults.ServerSentEvents(Events(list, ct))); + app.MapPost("/api/batches", (BatchList list) => list.NewBatch()); + await app.StartAsync(); return new GuiHost(app, new Uri(app.Urls.First()), token); } + static async IAsyncEnumerable> Events(BatchList list, [EnumeratorCancellation] CancellationToken ct) + { + using var sub = list.Subscribe(); + yield return new SseItem(new { batches = sub.Snapshot }, "snapshot"); + await foreach (var change in sub.Changes.ReadAllAsync(ct)) + yield return new SseItem(change.Data, change.Kind); + } + + public BatchList Batches => app.Services.GetRequiredService(); + /// The token in the query string becomes a cookie, then a redirect drops it from the URL. All other requests need the cookie. static Func RequireToken(string token) { diff --git a/tests/SqlRunboard.Gui.Tests/EventsTests.cs b/tests/SqlRunboard.Gui.Tests/EventsTests.cs new file mode 100644 index 0000000..6932013 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/EventsTests.cs @@ -0,0 +1,40 @@ +using System.Net; +using System.Net.ServerSentEvents; +using System.Text; +using System.Text.Json; + +public class EventsTests +{ + static readonly CancellationToken Ct = TestContext.Current.CancellationToken; + + [Fact] + public async Task The_stream_sends_the_full_state_first_then_each_change() + { + await using var app = await TestApp.StartAsync(); + app.Host.Batches.NewBatch(); + using var http = app.Client(); + + await using var stream = await http.GetStreamAsync("/events", Ct); + await using var events = SseParser.Create(stream).EnumerateAsync(Ct).GetAsyncEnumerator(Ct); + + Assert.True(await events.MoveNextAsync()); + Assert.Equal("snapshot", events.Current.EventType); + var snapshot = JsonDocument.Parse(events.Current.Data).RootElement; + var first = Assert.Single(snapshot.GetProperty("batches").EnumerateArray()); + Assert.Equal("Untitled 1", first.GetProperty("name").GetString()); + Assert.Equal(JsonValueKind.Null, first.GetProperty("file").ValueKind); + Assert.True(first.GetProperty("dirty").GetBoolean()); + Assert.Equal(4, first.GetProperty("settings").GetProperty("parallelLimit").GetInt32()); + Assert.Equal("Integrated", first.GetProperty("settings").GetProperty("target").GetProperty("auth").GetString()); + + var post = await http.PostAsync("/api/batches", new StringContent("{}", Encoding.UTF8, "application/json"), Ct); + Assert.Equal(HttpStatusCode.OK, post.StatusCode); + Assert.Equal("Untitled 2", JsonDocument.Parse(await post.Content.ReadAsStringAsync(Ct)).RootElement.GetProperty("name").GetString()); + + Assert.True(await events.MoveNextAsync()); + Assert.Equal("batch", events.Current.EventType); + var change = JsonDocument.Parse(events.Current.Data).RootElement; + Assert.Equal("Untitled 2", change.GetProperty("name").GetString()); + Assert.NotEqual(first.GetProperty("id").GetInt32(), change.GetProperty("id").GetInt32()); + } +} From 58f0c92da5e1db3a6e1ae2ac109a5dcd600c8f7e Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:22:06 +0200 Subject: [PATCH 05/96] feat(gui): serve the prototype page with SSE state and Ctrl+N, with Playwright tests --- src/SqlRunboard.Gui/wwwroot/index.html | 762 ++++++++++++++++++- tests/SqlRunboard.Gui.Tests/AppPageTest.cs | 33 + tests/SqlRunboard.Gui.Tests/NewBatchTests.cs | 39 + 3 files changed, 832 insertions(+), 2 deletions(-) create mode 100644 tests/SqlRunboard.Gui.Tests/AppPageTest.cs create mode 100644 tests/SqlRunboard.Gui.Tests/NewBatchTests.cs diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 547c082..45870f4 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -1,5 +1,763 @@ + -SQLRunboard - + + + +SQLRunboard + + + +
+ +
+ +
+
+
+
+
+ + +
+
+
+ +
+
+ + diff --git a/tests/SqlRunboard.Gui.Tests/AppPageTest.cs b/tests/SqlRunboard.Gui.Tests/AppPageTest.cs new file mode 100644 index 0000000..769de24 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/AppPageTest.cs @@ -0,0 +1,33 @@ +using Microsoft.Playwright.Xunit.v3; + +/// +/// Base for browser tests: each test gets its own real server (fakes, temporary app data) and a page +/// opened with the start URL. The browser comes from the BROWSER env var (chromium, firefox, webkit). +/// +public abstract class AppPageTest : PageTest +{ + protected TestApp App { get; private set; } = null!; + readonly List pageErrors = []; + + public override async ValueTask InitializeAsync() + { + await base.InitializeAsync(); + Context.WebError += (_, e) => pageErrors.Add(e.Error); // uncaught JS errors on any page fail the test + App = await TestApp.StartAsync(); + await OpenAsync(Page, App.Host.StartUrl); + } + + /// Opens the app and waits until the page has the first SSE snapshot. + protected static async Task OpenAsync(Microsoft.Playwright.IPage page, Uri url) + { + await page.GotoAsync(url.ToString()); + await page.Locator("html[data-connected]").WaitForAsync(new() { State = Microsoft.Playwright.WaitForSelectorState.Attached }); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + await App.DisposeAsync(); + Assert.Empty(pageErrors); + } +} diff --git a/tests/SqlRunboard.Gui.Tests/NewBatchTests.cs b/tests/SqlRunboard.Gui.Tests/NewBatchTests.cs new file mode 100644 index 0000000..b47a26d --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/NewBatchTests.cs @@ -0,0 +1,39 @@ +using Microsoft.Playwright; + +public class NewBatchTests : AppPageTest +{ + ILocator SideOption(IPage page, string name) => + page.GetByRole(AriaRole.Navigation, new() { Name = "Batches" }).GetByRole(AriaRole.Option, new() { Name = name }); + + [Fact] + public async Task The_start_url_opens_the_page_without_the_token() + { + await Expect(Page).ToHaveURLAsync(App.Host.BaseUrl.ToString()); + await Expect(Page.GetByRole(AriaRole.Navigation, new() { Name = "Batches" })).ToBeVisibleAsync(); + } + + [Fact] + public async Task Ctrl_N_adds_Untitled_1_to_the_side_list_and_opens_its_Settings_tab() + { + // The prototype maps Ctrl to Cmd on macOS. + await Page.Keyboard.PressAsync("ControlOrMeta+n"); + + await Expect(SideOption(Page, "Untitled 1")).ToHaveAttributeAsync("aria-selected", "true"); + await Expect(Page.GetByRole(AriaRole.Tab, new() { Name = "Settings" })).ToHaveAttributeAsync("aria-selected", "true"); + using var sub = App.Host.Batches.Subscribe(); + Assert.Equal("Untitled 1", Assert.Single(sub.Snapshot).Name); + } + + [Fact] + public async Task Another_open_page_gets_the_new_batch_from_the_stream() + { + var other = await Context.NewPageAsync(); + await OpenAsync(other, App.Host.BaseUrl); + + await Page.Keyboard.PressAsync("ControlOrMeta+n"); + await Page.Keyboard.PressAsync("ControlOrMeta+n"); + + await Expect(SideOption(other, "Untitled 1")).ToBeVisibleAsync(); + await Expect(SideOption(other, "Untitled 2")).ToBeVisibleAsync(); + } +} From ff9b63da6c5e3f004026ceec3d472c3d13e0a917 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:23:18 +0200 Subject: [PATCH 06/96] feat(cli): add sqlrunboard executable with --app-data and --no-browser --- src/sqlrunboard/Program.cs | 26 +++++++++ .../SqlRunboard.Gui.Tests/ExecutableTests.cs | 53 +++++++++++++++++++ .../SqlRunboard.Gui.Tests.csproj | 2 + 3 files changed, 81 insertions(+) create mode 100644 src/sqlrunboard/Program.cs create mode 100644 tests/SqlRunboard.Gui.Tests/ExecutableTests.cs diff --git a/src/sqlrunboard/Program.cs b/src/sqlrunboard/Program.cs new file mode 100644 index 0000000..1878354 --- /dev/null +++ b/src/sqlrunboard/Program.cs @@ -0,0 +1,26 @@ +using System.Diagnostics; +using SqlRunboard.Core; +using SqlRunboard.Gui; + +const string Usage = "Usage: sqlrunboard [--app-data ] [--no-browser]"; + +var appData = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "sqlrunboard"); +var openBrowser = true; +for (var i = 0; i < args.Length; i++) +{ + switch (args[i]) + { + case "--app-data" when i + 1 < args.Length: appData = args[++i]; break; + case "--no-browser": openBrowser = false; break; + default: Console.Error.WriteLine(Usage); return 2; + } +} + +var time = TimeProvider.System; +// ponytail: the fake executor runs until the real SQL Server adapter (ticket #7) replaces it. +await using var host = await GuiHost.StartAsync(new GuiHostOptions(appData, new FakeSqlExecutor(time), time)); +Console.WriteLine($"SQLRunboard: {host.StartUrl}"); +// UseShellExecute opens the default browser: start on Windows, open on macOS, xdg-open on Linux. +if (openBrowser) Process.Start(new ProcessStartInfo(host.StartUrl.ToString()) { UseShellExecute = true }); +await host.WaitForShutdownAsync(); // Ctrl+C stops the server +return 0; diff --git a/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs b/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs new file mode 100644 index 0000000..d9d9af8 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs @@ -0,0 +1,53 @@ +using System.Diagnostics; +using System.Net; + +public class ExecutableTests +{ + static readonly CancellationToken Ct = TestContext.Current.CancellationToken; + + [Fact] + public async Task Starts_the_server_in_the_given_app_data_folder_without_a_browser_and_prints_the_start_url() + { + var appData = Path.Combine(Path.GetTempPath(), "sqlrunboard-exe-" + Guid.NewGuid().ToString("N")); + var psi = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") + { + ArgumentList = { Path.Combine(AppContext.BaseDirectory, "sqlrunboard.dll"), "--app-data", appData, "--no-browser" }, + RedirectStandardOutput = true, + }; + using var exe = Process.Start(psi)!; + try + { + var line = await exe.StandardOutput.ReadLineAsync(Ct).AsTask().WaitAsync(TimeSpan.FromSeconds(30), Ct) ?? ""; + var url = new Uri(line[line.IndexOf("http")..]); + + using var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }); + var res = await http.GetAsync(url, Ct); + + Assert.Equal(HttpStatusCode.Redirect, res.StatusCode); + Assert.Equal("127.0.0.1", url.Host); + Assert.True(Directory.Exists(appData)); + } + finally + { + exe.Kill(entireProcessTree: true); + await exe.WaitForExitAsync(Ct); + if (Directory.Exists(appData)) Directory.Delete(appData, recursive: true); + } + } + + [Fact] + public async Task An_unknown_argument_prints_the_usage_and_exits_with_code_2() + { + var psi = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") + { + ArgumentList = { Path.Combine(AppContext.BaseDirectory, "sqlrunboard.dll"), "--bogus" }, + RedirectStandardError = true, + }; + using var exe = Process.Start(psi)!; + var error = await exe.StandardError.ReadToEndAsync(Ct); + await exe.WaitForExitAsync(Ct); + + Assert.Equal(2, exe.ExitCode); + Assert.Contains("Usage: sqlrunboard", error); + } +} diff --git a/tests/SqlRunboard.Gui.Tests/SqlRunboard.Gui.Tests.csproj b/tests/SqlRunboard.Gui.Tests/SqlRunboard.Gui.Tests.csproj index dac41d7..0d9770a 100644 --- a/tests/SqlRunboard.Gui.Tests/SqlRunboard.Gui.Tests.csproj +++ b/tests/SqlRunboard.Gui.Tests/SqlRunboard.Gui.Tests.csproj @@ -7,6 +7,8 @@ + + From 4024c555647e3a825d561ac0f92462ecc5e7e279 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:25:01 +0200 Subject: [PATCH 07/96] chore(ci): run all tests on Chromium, Firefox and WebKit on Windows, macOS and Linux --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..98b1ba0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: test (${{ matrix.os }}, ${{ matrix.browser }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + browser: [chromium, firefox, webkit] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + - run: dotnet build + # Playwright advises against caching the browsers. + - run: ./tests/SqlRunboard.Gui.Tests/bin/Debug/net10.0/playwright.ps1 install ${{ matrix.browser }} ${{ runner.os == 'Linux' && '--with-deps' || '' }} + - run: dotnet test --no-build + env: + BROWSER: ${{ matrix.browser }} From d00f3c68a6a6f2fe2e2990cd326769f272f69f7e Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:27:58 +0200 Subject: [PATCH 08/96] feat(gui): allow only the hosts 127.0.0.1 and localhost --- src/SqlRunboard.Gui/GuiHost.cs | 1 + .../RequestSecurityTests.cs | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 tests/SqlRunboard.Gui.Tests/RequestSecurityTests.cs diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 9789ed7..c8425af 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -39,6 +39,7 @@ public static async Task StartAsync(GuiHostOptions options) var builder = WebApplication.CreateBuilder(); builder.WebHost.UseKestrel(k => k.Listen(IPAddress.Loopback, 0)); + builder.Configuration["AllowedHosts"] = "127.0.0.1;localhost"; // the built-in host filtering answers 400 to other hosts builder.Logging.SetMinimumLevel(LogLevel.Warning); builder.Services.AddSingleton(options); builder.Services.AddSingleton(options.Executor); diff --git a/tests/SqlRunboard.Gui.Tests/RequestSecurityTests.cs b/tests/SqlRunboard.Gui.Tests/RequestSecurityTests.cs new file mode 100644 index 0000000..f668ace --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/RequestSecurityTests.cs @@ -0,0 +1,23 @@ +using System.Net; + +/// docs/SPEC.md "Security": Host, Origin and Content-Type checks. The token check is in HostTests. +public class RequestSecurityTests +{ + static readonly CancellationToken Ct = TestContext.Current.CancellationToken; + + [Theory] + [InlineData("127.0.0.1", HttpStatusCode.OK)] + [InlineData("localhost", HttpStatusCode.OK)] + [InlineData("evil.example", HttpStatusCode.BadRequest)] + [InlineData("127.0.0.1.evil.example", HttpStatusCode.BadRequest)] + public async Task Only_the_allowed_hosts_are_served(string host, HttpStatusCode expected) + { + await using var app = await TestApp.StartAsync(); + var req = new HttpRequestMessage(HttpMethod.Get, "/"); + req.Headers.Host = $"{host}:{app.Host.BaseUrl.Port}"; + + var res = await app.Client().SendAsync(req, Ct); + + Assert.Equal(expected, res.StatusCode); + } +} From e11bf5f9a690e45ffac7d98253368acb64c4894a Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:28:45 +0200 Subject: [PATCH 09/96] feat(gui): reject requests with an Origin header for a different origin --- src/SqlRunboard.Gui/GuiHost.cs | 13 +++++++++++++ .../RequestSecurityTests.cs | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index c8425af..103cbbe 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -48,6 +48,7 @@ public static async Task StartAsync(GuiHostOptions options) builder.Services.ConfigureHttpJsonOptions(j => j.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); var app = builder.Build(); + app.Use(RejectForeignRequests); app.Use(RequireToken(token)); var files = new EmbeddedFileProvider(typeof(GuiHost).Assembly, "SqlRunboard.Gui.wwwroot"); app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files }); @@ -71,6 +72,18 @@ static async IAsyncEnumerable> Events(BatchList list, [Enumerato public BatchList Batches => app.Services.GetRequiredService(); + /// Other web sites must not use the server: reject an Origin other than our own. + static async Task RejectForeignRequests(HttpContext ctx, RequestDelegate next) + { + var origin = ctx.Request.Headers.Origin; + if (origin.Count > 0 && origin != $"{ctx.Request.Scheme}://{ctx.Request.Host}") + { + ctx.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + await next(ctx); + } + /// The token in the query string becomes a cookie, then a redirect drops it from the URL. All other requests need the cookie. static Func RequireToken(string token) { diff --git a/tests/SqlRunboard.Gui.Tests/RequestSecurityTests.cs b/tests/SqlRunboard.Gui.Tests/RequestSecurityTests.cs index f668ace..6d93d06 100644 --- a/tests/SqlRunboard.Gui.Tests/RequestSecurityTests.cs +++ b/tests/SqlRunboard.Gui.Tests/RequestSecurityTests.cs @@ -20,4 +20,21 @@ public async Task Only_the_allowed_hosts_are_served(string host, HttpStatusCode Assert.Equal(expected, res.StatusCode); } + + [Theory] + [InlineData("http://127.0.0.1:{port}", HttpStatusCode.OK)] + [InlineData("http://evil.example", HttpStatusCode.Forbidden)] + [InlineData("http://localhost:{port}", HttpStatusCode.Forbidden)] + [InlineData("http://127.0.0.1:1", HttpStatusCode.Forbidden)] + [InlineData("null", HttpStatusCode.Forbidden)] + public async Task A_request_from_a_different_origin_is_rejected(string origin, HttpStatusCode expected) + { + await using var app = await TestApp.StartAsync(); + var req = new HttpRequestMessage(HttpMethod.Get, "/"); + req.Headers.Add("Origin", origin.Replace("{port}", app.Host.BaseUrl.Port.ToString())); + + var res = await app.Client().SendAsync(req, Ct); + + Assert.Equal(expected, res.StatusCode); + } } From 82dd086e9e72e05ead5be0db5d5a432c86312bb7 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:28:47 +0200 Subject: [PATCH 10/96] feat(gui): write a user-only lock file with the url and token at start --- src/SqlRunboard.Gui/GuiHost.cs | 22 +++++++++++++++++-- .../InstanceLockTests.cs | 18 +++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 tests/SqlRunboard.Gui.Tests/InstanceLockTests.cs diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 9789ed7..2b6d1fe 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -3,6 +3,7 @@ using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -22,10 +23,13 @@ public sealed record GuiHostOptions(string AppDataFolder, ISqlExecutor Executor, public sealed class GuiHost : IAsyncDisposable { public const string CookieName = "sqlrunboard"; + /// In the app data folder. JSON {"url","token"}. Shared by sqlrunboard and sqlrunboard-desktop. + public const string LockFileName = "sqlrunboard.lock"; readonly WebApplication app; + readonly string lockPath; - GuiHost(WebApplication app, Uri baseUrl, string token) { this.app = app; BaseUrl = baseUrl; Token = token; } + GuiHost(WebApplication app, Uri baseUrl, string token, string lockPath) { this.app = app; BaseUrl = baseUrl; Token = token; this.lockPath = lockPath; } public Uri BaseUrl { get; } public string Token { get; } @@ -57,7 +61,21 @@ public static async Task StartAsync(GuiHostOptions options) app.MapPost("/api/batches", (BatchList list) => list.NewBatch()); await app.StartAsync(); - return new GuiHost(app, new Uri(app.Urls.First()), token); + var host = new GuiHost(app, new Uri(app.Urls.First()), token, Path.Combine(options.AppDataFolder, LockFileName)); + host.WriteLock(); + return host; + } + + record LockFile(Uri Url, string Token); + + // ponytail: check-then-write, so two starts in the same instant can both run. Add an OS file lock if that matters. + void WriteLock() + { + File.Delete(lockPath); // a stale file can have other permissions; create it new with mode 600 + var fs = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write }; + if (!OperatingSystem.IsWindows()) fs.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + using var stream = new FileStream(lockPath, fs); + JsonSerializer.Serialize(stream, new LockFile(BaseUrl, Token), JsonSerializerOptions.Web); } static async IAsyncEnumerable> Events(BatchList list, [EnumeratorCancellation] CancellationToken ct) diff --git a/tests/SqlRunboard.Gui.Tests/InstanceLockTests.cs b/tests/SqlRunboard.Gui.Tests/InstanceLockTests.cs new file mode 100644 index 0000000..9bcbca6 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/InstanceLockTests.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using SqlRunboard.Gui; + +public class InstanceLockTests +{ + [Fact] + public async Task The_server_writes_a_lock_file_with_the_url_and_the_token_that_only_the_user_can_read() + { + await using var app = await TestApp.StartAsync(); + + var path = Path.Combine(app.AppData, GuiHost.LockFileName); + var json = JsonDocument.Parse(File.ReadAllText(path)).RootElement; + Assert.Equal(app.Host.BaseUrl, new Uri(json.GetProperty("url").GetString()!)); + Assert.Equal(app.Host.Token, json.GetProperty("token").GetString()); + if (!OperatingSystem.IsWindows()) + Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(path)); + } +} From d67d725df48b4ee32e983acea98b9813d91e7d5f Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:29:12 +0200 Subject: [PATCH 11/96] feat(core): scan the batch folder with ; filters in natural order --- src/SqlRunboard.Core/FileScan.cs | 25 ++++++++ tests/SqlRunboard.Core.Tests/FileScanTests.cs | 59 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/SqlRunboard.Core/FileScan.cs create mode 100644 tests/SqlRunboard.Core.Tests/FileScanTests.cs diff --git a/src/SqlRunboard.Core/FileScan.cs b/src/SqlRunboard.Core/FileScan.cs new file mode 100644 index 0000000..634e626 --- /dev/null +++ b/src/SqlRunboard.Core/FileScan.cs @@ -0,0 +1,25 @@ +using System.Globalization; + +namespace SqlRunboard.Core; + +/// Finds the script files of a batch. See docs/SPEC.md "Scripts" and "Script order". +public static class FileScan +{ + /// Natural sort on the relative path: 2_x.sql comes before 10_x.sql. + public static readonly IComparer NaturalOrder = + StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering); + + /// The files in folder that match one of the ;-separated filters, as paths relative to folder with /, in natural order. + public static IReadOnlyList Scan(string? folder, string filters, bool recurse) + { + if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) return []; + // Simple match: only * and ? are wildcards, without the Win32 quirks (8.3 names, "*.sql" also matching "x.sqlx"). + var options = new EnumerationOptions { MatchType = MatchType.Simple, RecurseSubdirectories = recurse, IgnoreInaccessible = true }; + return filters.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .SelectMany(f => Directory.EnumerateFiles(folder, f, options)) + .Select(f => Path.GetRelativePath(folder, f).Replace('\\', '/')) + .Distinct(StringComparer.Ordinal) + .Order(NaturalOrder).ThenBy(p => p, StringComparer.Ordinal) + .ToList(); + } +} diff --git a/tests/SqlRunboard.Core.Tests/FileScanTests.cs b/tests/SqlRunboard.Core.Tests/FileScanTests.cs new file mode 100644 index 0000000..13299b1 --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/FileScanTests.cs @@ -0,0 +1,59 @@ +using SqlRunboard.Core; + +public sealed class FileScanTests : IDisposable +{ + readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-scan-").FullName; + + void Touch(params string[] paths) + { + foreach (var p in paths) + { + var full = Path.Combine(root, p); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, "select 1"); + } + } + + [Fact] + public void Lists_the_matching_files_in_natural_order() + { + Touch("10_x.sql", "2_x.sql", "1_x.sql", "notes.txt"); + + Assert.Equal(["1_x.sql", "2_x.sql", "10_x.sql"], FileScan.Scan(root, "*.sql", recurse: false)); + } + + [Fact] + public void Many_filters_are_separated_by_semicolons() + { + Touch("a.sql", "b.tsql", "c.txt"); + + Assert.Equal(["a.sql", "b.tsql"], FileScan.Scan(root, " *.sql ; *.tsql;", recurse: false)); + } + + [Fact] + public void A_file_that_matches_two_filters_shows_once() + { + Touch("a.sql"); + + Assert.Equal(["a.sql"], FileScan.Scan(root, "*.sql;a.*", recurse: false)); + } + + [Fact] + public void Search_subfolders_gives_relative_paths_with_slashes() + { + Touch("b.sql", "sub/a.sql", "sub/deeper/c.sql"); + + Assert.Equal(["b.sql"], FileScan.Scan(root, "*.sql", recurse: false)); + Assert.Equal(["b.sql", "sub/a.sql", "sub/deeper/c.sql"], FileScan.Scan(root, "*.sql", recurse: true)); + } + + [Fact] + public void A_missing_or_empty_folder_gives_no_files() + { + Assert.Empty(FileScan.Scan(Path.Combine(root, "nope"), "*.sql", recurse: false)); + Assert.Empty(FileScan.Scan(null, "*.sql", recurse: false)); + Assert.Empty(FileScan.Scan("", "*.sql", recurse: false)); + } + + public void Dispose() => Directory.Delete(root, recursive: true); +} From 743fcc7f0c4f5cb4f245eaacc581db6d27c3e454 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:29:34 +0200 Subject: [PATCH 12/96] feat(gui): accept POST requests only with Content-Type application/json --- src/SqlRunboard.Gui/GuiHost.cs | 11 +++++++++- .../RequestSecurityTests.cs | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 103cbbe..9c80c50 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -72,7 +72,10 @@ static async IAsyncEnumerable> Events(BatchList list, [Enumerato public BatchList Batches => app.Services.GetRequiredService(); - /// Other web sites must not use the server: reject an Origin other than our own. + /// + /// Other web sites must not use the server: reject an Origin other than our own, and a POST that is not + /// application/json (a form or text/plain POST is a "simple" cross-site request with no CORS preflight). + /// static async Task RejectForeignRequests(HttpContext ctx, RequestDelegate next) { var origin = ctx.Request.Headers.Origin; @@ -81,6 +84,12 @@ static async Task RejectForeignRequests(HttpContext ctx, RequestDelegate next) ctx.Response.StatusCode = StatusCodes.Status403Forbidden; return; } + if (HttpMethods.IsPost(ctx.Request.Method) + && !string.Equals(ctx.Request.GetTypedHeaders().ContentType?.MediaType.Value, "application/json", StringComparison.OrdinalIgnoreCase)) + { + ctx.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType; + return; + } await next(ctx); } diff --git a/tests/SqlRunboard.Gui.Tests/RequestSecurityTests.cs b/tests/SqlRunboard.Gui.Tests/RequestSecurityTests.cs index 6d93d06..b6647b2 100644 --- a/tests/SqlRunboard.Gui.Tests/RequestSecurityTests.cs +++ b/tests/SqlRunboard.Gui.Tests/RequestSecurityTests.cs @@ -37,4 +37,24 @@ public async Task A_request_from_a_different_origin_is_rejected(string origin, H Assert.Equal(expected, res.StatusCode); } + + [Theory] + [InlineData("application/json", HttpStatusCode.OK)] + [InlineData("application/json; charset=utf-8", HttpStatusCode.OK)] + [InlineData(null, HttpStatusCode.UnsupportedMediaType)] + [InlineData("text/plain", HttpStatusCode.UnsupportedMediaType)] + [InlineData("application/x-www-form-urlencoded", HttpStatusCode.UnsupportedMediaType)] + [InlineData("multipart/form-data; boundary=x", HttpStatusCode.UnsupportedMediaType)] + public async Task A_POST_is_accepted_only_as_JSON(string? contentType, HttpStatusCode expected) + { + await using var app = await TestApp.StartAsync(); + var body = new ByteArrayContent("{}"u8.ToArray()); + if (contentType is not null) body.Headers.TryAddWithoutValidation("Content-Type", contentType); + + var res = await app.Client().PostAsync("/api/batches", body, Ct); + + Assert.Equal(expected, res.StatusCode); + using var sub = app.Host.Batches.Subscribe(); + Assert.Equal(expected == HttpStatusCode.OK ? 1 : 0, sub.Snapshot.Count); + } } From 4009e2f7327636b414da416fed3e17a97b49e5d8 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:29:37 +0200 Subject: [PATCH 13/96] feat(gui): find a running server from the lock file and ignore a stale one --- src/SqlRunboard.Gui/GuiHost.cs | 19 +++++++++++ .../InstanceLockTests.cs | 32 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 2b6d1fe..8b6767c 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -68,6 +68,25 @@ public static async Task StartAsync(GuiHostOptions options) record LockFile(Uri Url, string Token); + /// The start URL of the server in the lock file, or null when no server answers there with that token (a stale or missing lock file). + public static async Task FindRunningAsync(string appDataFolder) + { + try + { + var path = Path.Combine(appDataFolder, LockFileName); + if (!File.Exists(path)) return null; + if (JsonSerializer.Deserialize(File.ReadAllText(path), JsonSerializerOptions.Web) is not { Url: { } url, Token: { } token }) return null; + var startUrl = new Uri(url, "/?token=" + Uri.EscapeDataString(token)); + using var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false }) { Timeout = TimeSpan.FromSeconds(3) }; + using var res = await http.GetAsync(startUrl); + return res.StatusCode == HttpStatusCode.Redirect ? startUrl : null; // only our server with this token redirects + } + catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException or IOException or UriFormatException) + { + return null; + } + } + // ponytail: check-then-write, so two starts in the same instant can both run. Add an OS file lock if that matters. void WriteLock() { diff --git a/tests/SqlRunboard.Gui.Tests/InstanceLockTests.cs b/tests/SqlRunboard.Gui.Tests/InstanceLockTests.cs index 9bcbca6..449cb18 100644 --- a/tests/SqlRunboard.Gui.Tests/InstanceLockTests.cs +++ b/tests/SqlRunboard.Gui.Tests/InstanceLockTests.cs @@ -15,4 +15,36 @@ public async Task The_server_writes_a_lock_file_with_the_url_and_the_token_that_ if (!OperatingSystem.IsWindows()) Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(path)); } + + [Fact] + public async Task Finds_the_start_url_of_the_running_server() + { + await using var app = await TestApp.StartAsync(); + + Assert.Equal(app.Host.StartUrl, await GuiHost.FindRunningAsync(app.AppData)); + } + + [Fact] + public async Task Finds_nothing_without_a_lock_file() + { + var appData = Directory.CreateTempSubdirectory("sqlrunboard-test-").FullName; + try { Assert.Null(await GuiHost.FindRunningAsync(appData)); } + finally { Directory.Delete(appData, recursive: true); } + } + + [Fact] + public async Task A_stale_lock_file_does_not_block_a_start() + { + var app = await TestApp.StartAsync(); + var path = Path.Combine(app.AppData, GuiHost.LockFileName); + var stale = File.ReadAllText(path); + await app.Host.DisposeAsync(); // the server stops; a killed process leaves its lock file behind + File.WriteAllText(path, stale); + + Assert.Null(await GuiHost.FindRunningAsync(app.AppData)); + await using var next = await GuiHost.StartAsync(new GuiHostOptions(app.AppData, app.Executor, app.Time)); + Assert.Equal(next.StartUrl, await GuiHost.FindRunningAsync(app.AppData)); + await next.DisposeAsync(); + Directory.Delete(app.AppData, recursive: true); + } } From b462e07634e212fa316dcf91f64fcb2a78ba5d29 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:30:47 +0200 Subject: [PATCH 14/96] feat(cli): open the running server and stop when one already runs --- src/sqlrunboard/Program.cs | 18 +++++++++++--- .../SqlRunboard.Gui.Tests/ExecutableTests.cs | 24 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/sqlrunboard/Program.cs b/src/sqlrunboard/Program.cs index 1878354..6cd8a27 100644 --- a/src/sqlrunboard/Program.cs +++ b/src/sqlrunboard/Program.cs @@ -16,11 +16,23 @@ } } +// Single instance: a server already runs for this user, so open the browser to it and stop. +if (await GuiHost.FindRunningAsync(appData) is { } running) +{ + Open(running); + return 0; +} + var time = TimeProvider.System; // ponytail: the fake executor runs until the real SQL Server adapter (ticket #7) replaces it. await using var host = await GuiHost.StartAsync(new GuiHostOptions(appData, new FakeSqlExecutor(time), time)); -Console.WriteLine($"SQLRunboard: {host.StartUrl}"); -// UseShellExecute opens the default browser: start on Windows, open on macOS, xdg-open on Linux. -if (openBrowser) Process.Start(new ProcessStartInfo(host.StartUrl.ToString()) { UseShellExecute = true }); +Open(host.StartUrl); await host.WaitForShutdownAsync(); // Ctrl+C stops the server return 0; + +void Open(Uri url) +{ + Console.WriteLine($"SQLRunboard: {url}"); + // UseShellExecute opens the default browser: start on Windows, open on macOS, xdg-open on Linux. + if (openBrowser) Process.Start(new ProcessStartInfo(url.ToString()) { UseShellExecute = true }); +} diff --git a/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs b/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs index d9d9af8..e4494fb 100644 --- a/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs +++ b/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs @@ -35,6 +35,30 @@ public async Task Starts_the_server_in_the_given_app_data_folder_without_a_brows } } + [Fact] + public async Task A_second_start_opens_the_running_server_and_stops() + { + await using var app = await TestApp.StartAsync(); + var psi = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") + { + ArgumentList = { Path.Combine(AppContext.BaseDirectory, "sqlrunboard.dll"), "--app-data", app.AppData, "--no-browser" }, + RedirectStandardOutput = true, + }; + using var exe = Process.Start(psi)!; + try + { + var output = await exe.StandardOutput.ReadToEndAsync(Ct).WaitAsync(TimeSpan.FromSeconds(30), Ct); + await exe.WaitForExitAsync(Ct); + + Assert.Equal(0, exe.ExitCode); + Assert.Equal($"SQLRunboard: {app.Host.StartUrl}", output.Trim()); + } + finally + { + if (!exe.HasExited) exe.Kill(entireProcessTree: true); + } + } + [Fact] public async Task An_unknown_argument_prints_the_usage_and_exits_with_code_2() { From d46eb21e2222f48f079e4df655f8ff57c12309d8 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:35:42 +0200 Subject: [PATCH 15/96] test(cli): check that the browser executable has no native UI dependency --- tests/SqlRunboard.Gui.Tests/ExecutableTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs b/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs index e4494fb..39b4b2b 100644 --- a/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs +++ b/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs @@ -5,6 +5,15 @@ public class ExecutableTests { static readonly CancellationToken Ct = TestContext.Current.CancellationToken; + [Fact] + public void The_browser_executable_has_no_native_UI_dependency() + { + var deps = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "sqlrunboard.deps.json")); + + Assert.DoesNotContain("Avalonia", deps, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("WebView", deps, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task Starts_the_server_in_the_given_app_data_folder_without_a_browser_and_prints_the_start_url() { From 4c5f5fa8b012abadab1a88959c1880c80575b52f Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:35:47 +0200 Subject: [PATCH 16/96] feat(desktop): add sqlrunboard-desktop that shows the page in an Avalonia NativeWebView window --- SqlRunboard.slnx | 1 + src/sqlrunboard-desktop/Program.cs | 45 +++++++++++++++++++ .../sqlrunboard-desktop.csproj | 14 ++++++ 3 files changed, 60 insertions(+) create mode 100644 src/sqlrunboard-desktop/Program.cs create mode 100644 src/sqlrunboard-desktop/sqlrunboard-desktop.csproj diff --git a/SqlRunboard.slnx b/SqlRunboard.slnx index 94e5ab7..251eaa4 100644 --- a/SqlRunboard.slnx +++ b/SqlRunboard.slnx @@ -1,5 +1,6 @@ + diff --git a/src/sqlrunboard-desktop/Program.cs b/src/sqlrunboard-desktop/Program.cs new file mode 100644 index 0000000..4cbd80b --- /dev/null +++ b/src/sqlrunboard-desktop/Program.cs @@ -0,0 +1,45 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Templates; +using SqlRunboard.Core; +using SqlRunboard.Gui; + +const string Usage = "Usage: sqlrunboard-desktop [--app-data ]"; + +var appData = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "sqlrunboard"); +for (var i = 0; i < args.Length; i++) +{ + if (args[i] == "--app-data" && i + 1 < args.Length) appData = args[++i]; + else { Console.Error.WriteLine(Usage); return 2; } +} + +// Same lock file as the browser executable: if a server already runs, open a window to it. +// Block here, not with await: Avalonia must stay on the main thread (macOS). +GuiHost? host = null; +var startUrl = GuiHost.FindRunningAsync(appData).GetAwaiter().GetResult(); +if (startUrl is null) +{ + var time = TimeProvider.System; + // ponytail: the fake executor runs until the real SQL Server adapter (ticket #7) replaces it. + host = GuiHost.StartAsync(new GuiHostOptions(appData, new FakeSqlExecutor(time), time)).GetAwaiter().GetResult(); + startUrl = host.StartUrl; +} + +var lifetime = new ClassicDesktopStyleApplicationLifetime { Args = args, ShutdownMode = ShutdownMode.OnMainWindowClose }; +AppBuilder.Configure().UsePlatformDetect().SetupWithLifetime(lifetime); + +// The page loads from 127.0.0.1 with the token, as in the browser, so all server checks stay in force. +lifetime.MainWindow = new Window +{ + Title = "SQLRunboard", + Width = 1280, + Height = 800, + // No theme package: a bare template that shows the content. + Template = new FuncControlTemplate((w, _) => new ContentPresenter { [!ContentPresenter.ContentProperty] = w[!ContentControl.ContentProperty] }), + Content = new NativeWebView { Source = startUrl }, +}; +var code = lifetime.Start(args); +if (host is not null) host.DisposeAsync().AsTask().GetAwaiter().GetResult(); +return code; diff --git a/src/sqlrunboard-desktop/sqlrunboard-desktop.csproj b/src/sqlrunboard-desktop/sqlrunboard-desktop.csproj new file mode 100644 index 0000000..43052a5 --- /dev/null +++ b/src/sqlrunboard-desktop/sqlrunboard-desktop.csproj @@ -0,0 +1,14 @@ + + + + WinExe + sqlrunboard-desktop + SqlRunboard.Desktop + + + + + + + + From 8cbef0b6494e0d0b7a3ba19c81dd4da50eac325e Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:36:12 +0200 Subject: [PATCH 17/96] feat(core): keep the scripts of a batch and rescan when its settings change --- src/SqlRunboard.Core/Batch.cs | 7 ++++-- src/SqlRunboard.Core/BatchList.cs | 41 ++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/SqlRunboard.Core/Batch.cs b/src/SqlRunboard.Core/Batch.cs index 24c40f9..bc0cb44 100644 --- a/src/SqlRunboard.Core/Batch.cs +++ b/src/SqlRunboard.Core/Batch.cs @@ -30,5 +30,8 @@ public sealed record BatchSettings public BatchTarget Target { get; init; } = new(); } -/// One entry in the side list. File is null for an unsaved batch. -public sealed record Batch(int Id, string Name, string? File, bool Dirty, BatchSettings Settings); +/// One file of the batch. Path is relative to the folder, with /. Include false: a cleared check box. +public sealed record ScriptEntry(string Path, bool Include); + +/// One entry in the side list. File is null for an unsaved batch. Scripts is the start order. +public sealed record Batch(int Id, string Name, string? File, bool Dirty, BatchSettings Settings, IReadOnlyList Scripts); diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index 353661a..100f6c4 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -20,13 +20,52 @@ public Batch NewBatch() { lock (gate) { - var batch = new Batch(++lastId, $"Untitled {++lastUntitled}", File: null, Dirty: true, new BatchSettings()); + var batch = new Batch(++lastId, $"Untitled {++lastUntitled}", File: null, Dirty: true, new BatchSettings(), Scripts: []); batches.Add(batch); Publish(new Change("batch", batch)); return batch; } } + /// Changes the settings and scans the folder again. Null if no batch has this id. + public Batch? UpdateSettings(int id, BatchSettings settings) + { + lock (gate) + { + // ponytail: the scan runs under the lock; move it out if a slow network folder blocks the page. + return Replace(id, b => b with { Settings = settings, Scripts = Merge(b.Scripts, FileScan.Scan(FolderPath(b.File, settings.Folder), settings.Filters, settings.Recurse)) }); + } + } + + /// Sets or clears the check box of one file. Null if no batch has this id. + public Batch? SetInclude(int id, string path, bool include) + { + lock (gate) + return Replace(id, b => b with { Scripts = [.. b.Scripts.Select(s => s.Path == path ? s with { Include = include } : s)] }); + } + + // Call under the lock. + Batch? Replace(int id, Func change) + { + var i = batches.FindIndex(b => b.Id == id); + if (i < 0) return null; + var batch = batches[i] = change(batches[i]) with { Dirty = true }; + Publish(new Change("batch", batch)); + return batch; + } + + /// A relative folder is relative to the batch file. An unsaved batch has an absolute folder. + static string? FolderPath(string? batchFile, string? folder) => + string.IsNullOrWhiteSpace(folder) || batchFile is null ? folder : Path.Combine(Path.GetDirectoryName(batchFile)!, folder); + + /// Known files keep their place and check box; new files go at the bottom in file-name order. Files not on disk drop out. + static IReadOnlyList Merge(IReadOnlyList known, IReadOnlyList found) + { + var onDisk = found.ToHashSet(StringComparer.Ordinal); + var old = known.Select(s => s.Path).ToHashSet(StringComparer.Ordinal); + return [.. known.Where(s => onDisk.Contains(s.Path)), .. found.Where(p => !old.Contains(p)).Select(p => new ScriptEntry(p, Include: true))]; + } + /// The snapshot and the subscription are taken under one lock, so no change is lost between them. public Subscription Subscribe() { From 36feebc97b2ffd6cd48ae2293acb4b474f7e5bb9 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:36:15 +0200 Subject: [PATCH 18/96] feat(gui): save settings and check boxes on the server, add the folder dialog --- src/SqlRunboard.Gui/GuiHost.cs | 17 +++ src/SqlRunboard.Gui/wwwroot/index.html | 34 +++++- tests/SqlRunboard.Gui.Tests/HostTests.cs | 16 +++ tests/SqlRunboard.Gui.Tests/SettingsTests.cs | 122 +++++++++++++++++++ 4 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 tests/SqlRunboard.Gui.Tests/SettingsTests.cs diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 9789ed7..96267db 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -18,6 +18,9 @@ namespace SqlRunboard.Gui; /// Settings, lock file and logs of unsaved batches. Created if missing. public sealed record GuiHostOptions(string AppDataFolder, ISqlExecutor Executor, TimeProvider Time); +public sealed record IncludeRequest(string Path, bool Include); +public sealed record FolderRequest(string? Path); + /// The local web server: Kestrel on 127.0.0.1, a random port, a random token. See docs/SPEC.md "GUI host". public sealed class GuiHost : IAsyncDisposable { @@ -55,6 +58,20 @@ public static async Task StartAsync(GuiHostOptions options) // Server to page: SSE. Page to server: POST with a JSON body, one route per command. app.MapGet("/events", (BatchList list, CancellationToken ct) => TypedResults.ServerSentEvents(Events(list, ct))); app.MapPost("/api/batches", (BatchList list) => list.NewBatch()); + app.MapPost("/api/batches/{id:int}/settings", (int id, BatchSettings settings, BatchList list) => + settings is not { ParallelLimit: >= 1 and <= 64, TimeoutSeconds: null or >= 1, Filters: not null, Target: not null } ? Results.BadRequest() + : list.UpdateSettings(id, settings) is { } b ? Results.Ok(b) : Results.NotFound()); + app.MapPost("/api/batches/{id:int}/include", (int id, IncludeRequest r, BatchList list) => + list.SetInclude(id, r.Path, r.Include) is { } b ? Results.Ok(b) : Results.NotFound()); + // The browser has no folder picker that gives a path, so the page browses the folders here. + app.MapPost("/api/folders", (FolderRequest r) => + { + var path = string.IsNullOrWhiteSpace(r.Path) ? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) : Path.GetFullPath(r.Path); + if (!Directory.Exists(path)) return Results.NotFound(); + var folders = Directory.EnumerateDirectories(path, "*", new EnumerationOptions { IgnoreInaccessible = true }) + .Order(FileScan.NaturalOrder).Select(d => new { name = Path.GetFileName(d), path = d }); + return Results.Ok(new { path, parent = Path.GetDirectoryName(path), folders }); + }); await app.StartAsync(); return new GuiHost(app, new Uri(app.Urls.First()), token); diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 45870f4..3979b30 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -212,10 +212,16 @@ // The server keeps all batch state. Each server batch merges into a page object that also keeps the view state (tab, selection, drawer). // The page uses the flat settings of the prototype; the server uses the batch file shape (target is nested). const toView = sb => ({ ...sb, settings: { ...sb.settings, ...sb.settings.target, extra: sb.settings.target.extraConnectionString, timeoutSeconds: sb.settings.timeoutSeconds ?? '' } }); +const fromView = s => ({ folder: s.folder || null, filters: s.filters, recurse: s.recurse, parallelLimit: +s.parallelLimit, stopOnFirstFailure: s.stopOnFirstFailure, confirmBeforeRun: s.confirmBeforeRun, + timeoutSeconds: s.timeoutSeconds === '' ? null : +s.timeoutSeconds, + target: { server: s.server, database: s.database, auth: s.auth, user: s.user, passwordVariable: s.passwordVariable, encrypt: s.encrypt, trustServerCertificate: s.trustServerCertificate, extraConnectionString: s.extra } }); function upsert(sb) { let b = app.batches.find(x => x.id === sb.id); if (!b) app.batches.push(b = { missing: false, tab: null, sel: null, drawer: false, run: null, scripts: [], signing: null, logonMsg: null, testMsg: null, sort: null, fileSel: null }); - return Object.assign(b, toView(sb)); + const mine = b.saving ? b.settings : null; // while my settings are on the way, an older echo must not undo them + Object.assign(b, toView(sb)); + if (mine) b.settings = mine; + return b; } async function post(url, body = {}) { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); @@ -252,6 +258,17 @@ // ponytail: stand-ins for the commands of later tickets. Each ticket replaces its names with a POST. const todo = () => toast('Not available yet.'); const runBatch = todo, cancel = todo, cancelAll = todo, rerun = todo, move = todo, logon = todo, saveBatch = todo, openBatch = todo, removeBatch = todo; +// Edits go to the server one at a time, in the order you make them. +let edits = Promise.resolve(); +const sendEdit = (url, body) => edits = edits.then(() => post(url, body)).catch(e => { toast(e.message); return null; }); +function saveSettings(b) { + b.saving = (b.saving ?? 0) + 1; + sendEdit(`/api/batches/${b.id}/settings`, fromView(b.settings)).then(sb => { if (--b.saving) return; if (sb) upsert(sb); render(); }); +} +function setInclude(b, s, include) { s.include = include; render(); sendEdit(`/api/batches/${b.id}/include`, { path: s.path, include }); } +async function browse(b, path) { // the folder dialog: the server lists the folders + try { openDialog({ kind: 'folder', b, ...await post('/api/folders', { path }) }); } catch { if (path) browse(b, null); else toast('The folder list is not available.'); } +} const scriptText = () => null; // the file text comes with the Files tab function renderSource(lines, hl) { if (!lines) return '

The file is not on disk.

'; @@ -457,6 +474,7 @@ return `${lk ? '' : ''}

Scripts

${txt('folder', 'Folder')}${abs ? '
⚠ Not portable: an absolute folder.
' : ''} +
${txt('filters', 'File filters')}
Separate filters with ;. Only * and ? are wildcards.
${chk('recurse', 'Search subfolders')}

Run

@@ -552,6 +570,10 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} saveAs: () => `

Save as

The batch name is the file name without .sqlrunboard.json.

`, + folder: () => `

Select a folder

${esc(d.path)}

+
+ ${d.parent ? `` : ''}${d.folders.map(f => ``).join('')}
+
`, closeRun: () => `

Close SQLRunboard?

${esc(d.text)}

`, }[d.kind](); el.innerHTML = `
`; @@ -560,6 +582,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} function dialogOk() { const d = app.dialog, v = document.getElementById('dlg-in')?.value ?? ''; if (d.kind === 'password') { if (!v) return; d.b.password = v; app.dialog = null; renderDialog(); logon(d.b, d.then); return; } + if (d.kind === 'folder') { d.b.settings.folder = d.path; saveSettings(d.b); closeDialog(); return; } if (d.kind === 'confirm') { app.dialog = null; renderDialog(); d.then(); render(); return; } if (d.kind === 'saveAs') { if (!v.trim()) return; @@ -611,6 +634,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} case 'closeDrawer': b.drawer = false; render(); return document.querySelector(`[data-k="s:${CSS.escape(b.sel)}"]`)?.focus(); case 'runlog': return todo(); case 'test': return logon(b, login => { b.testMsg = `Connected. Server ${b.settings.server.split('.')[0].toUpperCase()}, database ${b.settings.database}, login ${login}.`; }); + case 'browse': return browse(b, b.settings.folder); case 'sortFiles': b.scripts.sort((x, y) => x.path.localeCompare(y.path, undefined, { numeric: true, sensitivity: 'base' })); b.dirty = true; return render(); } } @@ -620,6 +644,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} if (app.menu && !e.target.closest('#menu')) closeMenu(false); const t = e.target, b = cur(); const dlg = t.closest('[data-dlg]'); if (dlg) { e.preventDefault(); return dlg.dataset.dlg === 'ok' ? dialogOk() : closeDialog(); } + const fo = t.closest('[data-folder]'); if (fo) return browse(app.dialog.b, fo.dataset.folder); const mc = t.closest('[data-mcmd]'); if (mc) { const { b: mb, path } = app.menu, c = mc.dataset.mcmd; closeMenu(); @@ -651,11 +676,12 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} if (t.id === 'theme') { app.theme = t.checked ? 'Dark' : 'Light'; store.set('theme', app.theme); return applyTheme(); } if (t.dataset.tl) { app[t.dataset.tl] = t.checked; store.set(t.dataset.tl, t.checked); return render(); } if (!b) return; - if (t.dataset.include) { b.scripts.find(s => s.path === t.dataset.include).include = t.checked; b.dirty = true; return render(); } + if (t.dataset.include) return setInclude(b, b.scripts.find(s => s.path === t.dataset.include), t.checked); if (t.dataset.set) { const k = t.dataset.set; let v = t.type === 'checkbox' ? t.checked : t.value; if (k === 'parallelLimit') { v = Math.min(64, Math.max(1, Math.round(+v || 1))); t.value = v; } - b.settings[k] = v; b.dirty = true; b.testMsg = null; + if (k === 'timeoutSeconds' && v !== '') { v = Math.max(1, Math.round(+v) || 1); t.value = v; } + b.settings[k] = v; b.dirty = true; b.testMsg = null; saveSettings(b); render({ tick: k !== 'auth' }); // the logon type changes which fields show } }); @@ -705,7 +731,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} const list = b.scripts.map(s => s.path), i = list.indexOf(fr.dataset.file); if (e.altKey && dirs[e.key]) { e.preventDefault(); if (!locked(b) && moveIn(b.scripts, b.scripts[i], dirs[e.key])) b.dirty = true; render(); } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); b.fileSel = list[Math.max(0, Math.min(list.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)))]; render(); } - else if (e.key === ' ' && !locked(b) && b.scripts[i].state !== 'Missing') { e.preventDefault(); b.scripts[i].include = !b.scripts[i].include; b.dirty = true; render(); } + else if (e.key === ' ' && !locked(b) && b.scripts[i].state !== 'Missing') { e.preventDefault(); setInclude(b, b.scripts[i], !b.scripts[i].include); } else return; return document.querySelector(`[data-k="f:${CSS.escape(b.fileSel)}"]`)?.focus(); } diff --git a/tests/SqlRunboard.Gui.Tests/HostTests.cs b/tests/SqlRunboard.Gui.Tests/HostTests.cs index 1f6e363..25ad949 100644 --- a/tests/SqlRunboard.Gui.Tests/HostTests.cs +++ b/tests/SqlRunboard.Gui.Tests/HostTests.cs @@ -58,4 +58,20 @@ public async Task A_request_without_a_valid_token_or_cookie_is_rejected(string p Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync(path, Ct)).StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, (await withBadCookie.GetAsync(path, Ct)).StatusCode); } + + [Theory] + [InlineData("""{"parallelLimit":1,"timeoutSeconds":1,"filters":"*.sql","target":{}}""", HttpStatusCode.OK)] + [InlineData("""{"parallelLimit":64,"filters":"*.sql","target":{}}""", HttpStatusCode.OK)] + [InlineData("""{"parallelLimit":0,"filters":"*.sql","target":{}}""", HttpStatusCode.BadRequest)] + [InlineData("""{"parallelLimit":65,"filters":"*.sql","target":{}}""", HttpStatusCode.BadRequest)] + [InlineData("""{"parallelLimit":4,"timeoutSeconds":0,"filters":"*.sql","target":{}}""", HttpStatusCode.BadRequest)] + public async Task The_server_checks_the_settings_range(string json, HttpStatusCode expected) + { + await using var app = await TestApp.StartAsync(); + var batch = app.Host.Batches.NewBatch(); + + var res = await app.Client().PostAsync($"/api/batches/{batch.Id}/settings", new StringContent(json, System.Text.Encoding.UTF8, "application/json"), Ct); + + Assert.Equal(expected, res.StatusCode); + } } diff --git a/tests/SqlRunboard.Gui.Tests/SettingsTests.cs b/tests/SqlRunboard.Gui.Tests/SettingsTests.cs new file mode 100644 index 0000000..5f8af5b --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/SettingsTests.cs @@ -0,0 +1,122 @@ +using Microsoft.Playwright; + +public class SettingsTests : AppPageTest +{ + readonly string scripts = Directory.CreateTempSubdirectory("sqlrunboard-scripts-").FullName; + + void Touch(params string[] paths) + { + foreach (var p in paths) + { + var full = Path.Combine(scripts, p); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, "select 1"); + } + } + + ILocator Panel => Page.Locator("#panel"); + ILocator Tab(string name) => Page.GetByRole(AriaRole.Tab, new() { Name = name }); + ILocator FileRows => Panel.Locator("tbody tr"); + + async Task NewBatchAsync() + { + await Page.Keyboard.PressAsync("ControlOrMeta+n"); + await Expect(Tab("Settings")).ToHaveAttributeAsync("aria-selected", "true"); + } + + async Task SetFolderAsync(string folder) + { + await Panel.GetByLabel("Folder", new() { Exact = true }).FillAsync(folder); + await Panel.GetByLabel("Folder", new() { Exact = true }).PressAsync("Enter"); + } + + [Fact] + public async Task The_Settings_tab_shows_the_spec_defaults() + { + await NewBatchAsync(); + + await Expect(Panel.GetByLabel("File filters")).ToHaveValueAsync("*.sql"); + await Expect(Panel.GetByLabel("Search subfolders")).Not.ToBeCheckedAsync(); + await Expect(Panel.GetByLabel("Parallel limit")).ToHaveValueAsync("4"); + await Expect(Panel.GetByLabel("Parallel limit")).ToHaveAttributeAsync("min", "1"); + await Expect(Panel.GetByLabel("Parallel limit")).ToHaveAttributeAsync("max", "64"); + await Expect(Panel.GetByLabel("Stop the queue at the first failure")).Not.ToBeCheckedAsync(); + await Expect(Panel.GetByLabel("Confirm before run")).Not.ToBeCheckedAsync(); + await Expect(Panel.GetByLabel("Command timeout (s)")).ToHaveValueAsync(""); + } + + [Fact] + public async Task The_Files_tab_shows_the_matching_files_in_natural_order() + { + Touch("10_x.sql", "2_x.sql", "1_x.tsql", "notes.txt", "sub/3_x.sql"); + await NewBatchAsync(); + + await Panel.GetByLabel("File filters").FillAsync("*.sql;*.tsql"); + await Panel.GetByLabel("Search subfolders").CheckAsync(); + await SetFolderAsync(scripts); + await Tab("Files").ClickAsync(); + + await Expect(FileRows.Locator("td.tnum")).ToHaveTextAsync(["1_x.tsql", "2_x.sql", "10_x.sql", "sub/3_x.sql"]); + } + + [Fact] + public async Task A_cleared_check_box_is_kept_by_the_server() + { + Touch("a.sql", "b.sql"); + await NewBatchAsync(); + await SetFolderAsync(scripts); + await Tab("Files").ClickAsync(); + + await Panel.GetByLabel("Include a.sql").UncheckAsync(); + + var other = await Context.NewPageAsync(); + await OpenAsync(other, App.Host.BaseUrl); + await other.GetByRole(AriaRole.Tab, new() { Name = "Files" }).ClickAsync(); + await Expect(other.GetByLabel("Include a.sql")).Not.ToBeCheckedAsync(); + await Expect(other.GetByLabel("Include b.sql")).ToBeCheckedAsync(); + + await Panel.GetByLabel("Include a.sql").CheckAsync(); + await Expect(other.GetByLabel("Include a.sql")).ToBeCheckedAsync(); + } + + [Fact] + public async Task A_settings_change_goes_to_the_server() + { + await NewBatchAsync(); + + await Panel.GetByLabel("Parallel limit").FillAsync("100"); + await Panel.GetByLabel("Parallel limit").PressAsync("Enter"); + await Panel.GetByLabel("Command timeout (s)").FillAsync("30"); + await Panel.GetByLabel("Command timeout (s)").PressAsync("Enter"); + + var other = await Context.NewPageAsync(); + await OpenAsync(other, App.Host.BaseUrl); + await other.GetByRole(AriaRole.Tab, new() { Name = "Settings" }).ClickAsync(); + await Expect(other.GetByLabel("Parallel limit")).ToHaveValueAsync("64"); + await Expect(other.GetByLabel("Command timeout (s)")).ToHaveValueAsync("30"); + } + + [Fact] + public async Task The_folder_dialog_selects_a_folder_from_the_server_list() + { + Touch("deploy/1_a.sql"); + await NewBatchAsync(); + await SetFolderAsync(scripts); + + await Panel.GetByRole(AriaRole.Button, new() { Name = "Browse" }).ClickAsync(); + var dialog = Page.GetByRole(AriaRole.Dialog); + await dialog.GetByRole(AriaRole.Button, new() { Name = "deploy", Exact = true }).ClickAsync(); + await dialog.GetByRole(AriaRole.Button, new() { Name = "Select this folder" }).ClickAsync(); + + await Expect(dialog).ToHaveCountAsync(0); + await Expect(Panel.GetByLabel("Folder", new() { Exact = true })).ToHaveValueAsync(Path.Combine(scripts, "deploy")); + await Tab("Files").ClickAsync(); + await Expect(FileRows.Locator("td.tnum")).ToHaveTextAsync(["1_a.sql"]); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + Directory.Delete(scripts, recursive: true); + } +} From c27238f758d822f0d68b726750d206b422a9033c Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:41:38 +0200 Subject: [PATCH 19/96] chore(ci): add release workflow that publishes single-file archives on a v* tag --- .github/workflows/release.yml | 77 +++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3457bba --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,77 @@ +name: Release + +# Push a tag such as v0.1.0 to publish a release. +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + publish: + name: publish (${{ matrix.rid }}) + strategy: + fail-fast: false + matrix: + include: + - { rid: win-x64, os: windows-latest } + # macOS runner: the SDK gives the apphost an ad-hoc signature, which Apple silicon needs. + - { rid: osx-arm64, os: macos-latest } + - { rid: linux-x64, os: ubuntu-latest } + - { rid: linux-arm64, os: ubuntu-latest } + runs-on: ${{ matrix.os }} + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + - name: Publish and pack + run: | + $rid = '${{ matrix.rid }}' + $out = "publish/$rid" + foreach ($project in 'sqlrunboard', 'sqlrunboard-desktop') { + dotnet publish "src/$project" -c Release -r $rid --self-contained ` + -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -o $out + if ($LASTEXITCODE) { exit $LASTEXITCODE } + } + # Pack only the two executables (the win-x64 output also has native .pdb files). + $ext = $rid.StartsWith('win') ? '.exe' : '' + $files = "sqlrunboard$ext", "sqlrunboard-desktop$ext" + $name = "sqlrunboard-${{ github.ref_name }}-$rid" + # tar keeps the Unix execute bit. Windows users expect a zip. + if ($ext) { Compress-Archive -Path ($files | ForEach-Object { "$out/$_" }) -DestinationPath "$name.zip" } + else { tar -czf "$name.tar.gz" -C $out @files } + if ($LASTEXITCODE) { exit $LASTEXITCODE } + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.rid }} + path: sqlrunboard-* + if-no-files-found: error + + release: + needs: publish + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v5 + with: + path: dist + merge-multiple: true + - name: Create release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $notes = @' + Each archive contains `sqlrunboard` (opens the GUI in your browser) and `sqlrunboard-desktop` (opens the GUI in its own window). The executables are self-contained: you do not need to install .NET. + + The executables are not code signed. + + - macOS: the system quarantines downloaded files. After you extract the archive, run `xattr -d com.apple.quarantine sqlrunboard sqlrunboard-desktop` in the folder. + - Windows: SmartScreen can show "Windows protected your PC". Click "More info", then "Run anyway". + - Linux: `sqlrunboard-desktop` needs WebKitGTK (for example `libwebkit2gtk-4.1-0`) on the computer. + '@ + gh release create '${{ github.ref_name }}' dist/* --repo '${{ github.repository }}' --title '${{ github.ref_name }}' --notes $notes --generate-notes From 898b192c4de48e550641ede32a020a64701cdd5d Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:42:38 +0200 Subject: [PATCH 20/96] feat(core): save a batch to a version 1 batch file with a relative folder and a change check --- src/SqlRunboard.Core/Batch.cs | 3 + src/SqlRunboard.Core/BatchFile.cs | 43 +++++++ src/SqlRunboard.Core/BatchList.cs | 38 ++++++ src/SqlRunboard.Gui/GuiHost.cs | 2 +- .../SqlRunboard.Core.Tests/BatchFileTests.cs | 119 ++++++++++++++++++ 5 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 src/SqlRunboard.Core/BatchFile.cs create mode 100644 tests/SqlRunboard.Core.Tests/BatchFileTests.cs diff --git a/src/SqlRunboard.Core/Batch.cs b/src/SqlRunboard.Core/Batch.cs index bc0cb44..ae06792 100644 --- a/src/SqlRunboard.Core/Batch.cs +++ b/src/SqlRunboard.Core/Batch.cs @@ -28,6 +28,9 @@ public sealed record BatchSettings public bool ConfirmBeforeRun { get; init; } public int? TimeoutSeconds { get; init; } public BatchTarget Target { get; init; } = new(); + + /// The ranges of docs/SPEC.md "Batch". JSON input can also give null for the non-null properties. + public bool IsValid => this is { ParallelLimit: >= 1 and <= 64, TimeoutSeconds: null or >= 1, Filters: not null, Target: not null }; } /// One file of the batch. Path is relative to the folder, with /. Include false: a cleared check box. diff --git a/src/SqlRunboard.Core/BatchFile.cs b/src/SqlRunboard.Core/BatchFile.cs new file mode 100644 index 0000000..4507b7b --- /dev/null +++ b/src/SqlRunboard.Core/BatchFile.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SqlRunboard.Core; + +/// The file on disk changed after the app loaded or saved it. The user must choose: overwrite it, or load it again. +public sealed class BatchFileChangedException(string file) : IOException($"The file changed on disk: {file}"); + +/// The batch file, schema version 1. See docs/SPEC.md "Batch". It holds no secrets: the password is never in BatchSettings. +public static class BatchFile +{ + public const string Extension = ".sqlrunboard.json"; + + // A key that is not in the file gets the spec default. + sealed record Json(int Version, string? Folder = null, string Filters = "*.sql", bool Recurse = false, int ParallelLimit = 4, bool StopOnFirstFailure = false, + bool ConfirmBeforeRun = false, int? TimeoutSeconds = null, BatchTarget? Target = null, IReadOnlyList? Scripts = null); + + static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { WriteIndented = true, Converters = { new JsonStringEnumConverter() } }; + + public static bool HasExtension(string path) => path.EndsWith(Extension, StringComparison.OrdinalIgnoreCase); + + /// The batch name: the file name without .sqlrunboard.json. + public static string NameOf(string file) => Path.GetFileName(file)[..^Extension.Length]; + + public static string Format(BatchSettings s, IReadOnlyList scripts) => + JsonSerializer.Serialize(new Json(1, s.Folder, s.Filters, s.Recurse, s.ParallelLimit, s.StopOnFirstFailure, s.ConfirmBeforeRun, s.TimeoutSeconds, s.Target, scripts), Options); + + /// Throws InvalidDataException for an incorrect file. + public static (BatchSettings Settings, IReadOnlyList Scripts) Parse(string text) + { + Json? j; + try { j = JsonSerializer.Deserialize(text, Options); } + catch (JsonException e) { throw new InvalidDataException($"Not a batch file: {e.Message}"); } + if (j is not { Version: 1 }) throw new InvalidDataException("Not a batch file of version 1."); + var settings = new BatchSettings + { + Folder = j.Folder, Filters = j.Filters, Recurse = j.Recurse, ParallelLimit = j.ParallelLimit, StopOnFirstFailure = j.StopOnFirstFailure, + ConfirmBeforeRun = j.ConfirmBeforeRun, TimeoutSeconds = j.TimeoutSeconds, Target = j.Target ?? new(), + }; + if (!settings.IsValid || j.Scripts?.Any(s => s?.Path is null) == true) throw new InvalidDataException("The batch file has an incorrect setting."); + return (settings, j.Scripts ?? []); + } +} diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index 100f6c4..53130b4 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -14,6 +14,7 @@ public sealed class BatchList readonly Lock gate = new(); readonly List batches = []; readonly List> subscribers = []; + readonly Dictionary loaded = []; // batch id -> the file text that the app last read or wrote int lastId, lastUntitled; public Batch NewBatch() @@ -44,6 +45,43 @@ public Batch NewBatch() return Replace(id, b => b with { Scripts = [.. b.Scripts.Select(s => s.Path == path ? s with { Include = include } : s)] }); } + /// + /// Writes the batch file. Path null: Save, to the current file. Otherwise Save as, to that full path; the app adds .sqlrunboard.json if it is not there. + /// Throws BatchFileChangedException if the file on disk is not the version that the app loaded, unless overwrite is true. Null if no batch has this id. + /// + public Batch? Save(int id, string? path, bool overwrite) + { + if (path is not null && !Path.IsPathFullyQualified(path)) throw new ArgumentException($"Type a full path: {path}"); + lock (gate) + { + var i = batches.FindIndex(b => b.Id == id); + if (i < 0) return null; + var b = batches[i]; + var file = path is null ? b.File ?? throw new ArgumentException("The batch has no file. Use Save as.") + : BatchFile.HasExtension(path) ? Path.GetFullPath(path) : Path.GetFullPath(path + BatchFile.Extension); + // ponytail: Save as over another existing file overwrites it without a question; ask first if users lose files that way. + if (!overwrite && file == b.File && File.Exists(file) && File.ReadAllText(file) != loaded.GetValueOrDefault(id)) throw new BatchFileChangedException(file); + var settings = b.Settings with { Folder = Rebase(b.File, file, b.Settings.Folder) }; + var text = BatchFile.Format(settings, b.Scripts); + File.WriteAllText(file, text); + loaded[id] = text; + var batch = batches[i] = b with { Name = BatchFile.NameOf(file), File = file, Dirty = false, Settings = settings }; + Publish(new Change("batch", batch)); + return batch; + } + } + + /// + /// The folder for a batch file at newFile. A relative folder (or any folder of an unsaved batch) becomes relative to newFile, with /. + /// It stays absolute if no relative path is possible (another Windows drive). An absolute folder of a saved batch is your choice and stays. + /// + static string? Rebase(string? oldFile, string newFile, string? folder) + { + if (string.IsNullOrWhiteSpace(folder) || (oldFile is not null && Path.IsPathRooted(folder))) return folder; + var rel = Path.GetRelativePath(Path.GetDirectoryName(newFile)!, Path.GetFullPath(FolderPath(oldFile, folder)!)); + return Path.IsPathRooted(rel) ? rel : rel.Replace('\\', '/'); + } + // Call under the lock. Batch? Replace(int id, Func change) { diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 69a1cef..a1c927b 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -65,7 +65,7 @@ public static async Task StartAsync(GuiHostOptions options) app.MapGet("/events", (BatchList list, CancellationToken ct) => TypedResults.ServerSentEvents(Events(list, ct))); app.MapPost("/api/batches", (BatchList list) => list.NewBatch()); app.MapPost("/api/batches/{id:int}/settings", (int id, BatchSettings settings, BatchList list) => - settings is not { ParallelLimit: >= 1 and <= 64, TimeoutSeconds: null or >= 1, Filters: not null, Target: not null } ? Results.BadRequest() + !settings.IsValid ? Results.BadRequest() : list.UpdateSettings(id, settings) is { } b ? Results.Ok(b) : Results.NotFound()); app.MapPost("/api/batches/{id:int}/include", (int id, IncludeRequest r, BatchList list) => list.SetInclude(id, r.Path, r.Include) is { } b ? Results.Ok(b) : Results.NotFound()); diff --git a/tests/SqlRunboard.Core.Tests/BatchFileTests.cs b/tests/SqlRunboard.Core.Tests/BatchFileTests.cs new file mode 100644 index 0000000..bbf1fa8 --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/BatchFileTests.cs @@ -0,0 +1,119 @@ +using SqlRunboard.Core; + +/// Save, Save as, Open, Load again and Remove on the side list, against real temp folders. +public sealed class BatchFileTests : IDisposable +{ + readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-batch-").FullName; + readonly BatchList list = new(); + + void Touch(params string[] paths) + { + foreach (var p in paths) + { + var full = Path.Combine(root, p); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, "select 1"); + } + } + + Batch NewBatchWithFolder(string folder) + { + var b = list.NewBatch(); + return list.UpdateSettings(b.Id, new BatchSettings { Folder = folder })!; + } + + [Fact] + public void Save_as_writes_schema_version_1_with_the_folder_relative_to_the_batch_file() + { + Touch("scripts/001_a.sql", "scripts/002_b.sql"); + var b = NewBatchWithFolder(Path.Combine(root, "scripts")); + list.SetInclude(b.Id, "002_b.sql", false); + list.UpdateSettings(b.Id, b.Settings with { ConfirmBeforeRun = true, Target = new BatchTarget { Server = "sql01", Database = "Sales" } }); + + var saved = list.Save(b.Id, Path.Combine(root, "deploy"), overwrite: false)!; + + Assert.Equal("deploy", saved.Name); + Assert.Equal(Path.Combine(root, "deploy.sqlrunboard.json"), saved.File); + Assert.False(saved.Dirty); + Assert.Equal("scripts", saved.Settings.Folder); + Assert.Equal(""" + { + "version": 1, + "folder": "scripts", + "filters": "*.sql", + "recurse": false, + "parallelLimit": 4, + "stopOnFirstFailure": false, + "confirmBeforeRun": true, + "timeoutSeconds": null, + "target": { + "server": "sql01", + "database": "Sales", + "auth": "Integrated", + "user": null, + "passwordVariable": null, + "encrypt": "Mandatory", + "trustServerCertificate": false, + "extraConnectionString": "" + }, + "scripts": [ + { + "path": "001_a.sql", + "include": true + }, + { + "path": "002_b.sql", + "include": false + } + ] + } + """.ReplaceLineEndings("\n"), File.ReadAllText(saved.File!).ReplaceLineEndings("\n").TrimEnd()); + } + + [Fact] + public void Save_asks_before_it_overwrites_a_file_that_changed_on_disk() + { + var b = list.Save(list.NewBatch().Id, Path.Combine(root, "x.sqlrunboard.json"), overwrite: false)!; + Assert.NotNull(list.Save(b.Id, null, overwrite: false)); // unchanged on disk: no question + File.WriteAllText(b.File!, File.ReadAllText(b.File!).Replace("\"recurse\": false", "\"recurse\": true")); + + Assert.Throws(() => list.Save(b.Id, null, overwrite: false)); + list.Save(b.Id, null, overwrite: true); + Assert.Contains("\"recurse\": false", File.ReadAllText(b.File!)); + } + + [Fact] + public void Save_of_an_unsaved_batch_needs_a_path() + { + var b = list.NewBatch(); + + Assert.Throws(() => list.Save(b.Id, null, overwrite: false)); + Assert.Throws(() => list.Save(b.Id, "relative/x", overwrite: false)); + } + + [Fact] + public void Save_as_to_another_folder_keeps_a_relative_folder_pointing_at_the_same_scripts() + { + Touch("scripts/a.sql"); + var b = NewBatchWithFolder(Path.Combine(root, "scripts")); + list.Save(b.Id, Path.Combine(root, "x"), overwrite: false); + Directory.CreateDirectory(Path.Combine(root, "sub")); + + var moved = list.Save(b.Id, Path.Combine(root, "sub", "y.sqlrunboard.json"), overwrite: false)!; + + Assert.Equal("../scripts", moved.Settings.Folder); + Assert.Equal("y", moved.Name); + } + + [Fact] + public void An_absolute_folder_stays_absolute_after_the_first_save() + { + var b = list.Save(list.NewBatch().Id, Path.Combine(root, "x"), overwrite: false)!; + var other = Path.Combine(root, "elsewhere"); + list.UpdateSettings(b.Id, b.Settings with { Folder = other }); + + Assert.Equal(other, list.Save(b.Id, null, overwrite: false)!.Settings.Folder); + } + + public void Dispose() => Directory.Delete(root, recursive: true); +} From 5f27c976e9a4fbc4d49b45827a8b2fb247a4d735 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:43:26 +0200 Subject: [PATCH 21/96] feat(core): open, load again and remove batch files in the side list --- src/SqlRunboard.Core/BatchList.cs | 54 +++++++++++++ .../SqlRunboard.Core.Tests/BatchFileTests.cs | 78 +++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index 53130b4..7450331 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -71,6 +71,60 @@ public Batch NewBatch() } } + /// + /// Adds a batch file to the list, or gives the entry that already has this file. The path must be full and end with .sqlrunboard.json. + /// Throws FileNotFoundException, or InvalidDataException for an incorrect file. + /// + public Batch Open(string path) + { + if (!Path.IsPathFullyQualified(path) || !BatchFile.HasExtension(path)) throw new ArgumentException($"Select a full path to a {BatchFile.Extension} file: {path}"); + var file = Path.GetFullPath(path); + lock (gate) + { + // ponytail: an ordinal path compare; the same file with another letter case on Windows or macOS gets a second entry. + if (batches.Find(b => b.File == file) is { } open) return open; + var batch = Load(new Batch(++lastId, BatchFile.NameOf(file), file, Dirty: false, new BatchSettings(), Scripts: [])); + batches.Add(batch); + Publish(new Change("batch", batch)); + return batch; + } + } + + /// "Load again": replaces the batch with its file on disk. Null if no batch has this id. + public Batch? Reload(int id) + { + lock (gate) + { + var i = batches.FindIndex(b => b.Id == id); + if (i < 0) return null; + if (batches[i].File is null) throw new ArgumentException("The batch has no file."); + var batch = batches[i] = Load(batches[i]); + Publish(new Change("batch", batch)); + return batch; + } + } + + // Call under the lock. Reads b.File and scans its folder. + Batch Load(Batch b) + { + var text = File.ReadAllText(b.File!); + var (settings, scripts) = BatchFile.Parse(text); + loaded[b.Id] = text; + return b with { Dirty = false, Settings = settings, Scripts = Merge(scripts, FileScan.Scan(FolderPath(b.File, settings.Folder), settings.Filters, settings.Recurse)) }; + } + + /// "Remove from list". The file stays on disk. False if no batch has this id. + public bool Remove(int id) + { + lock (gate) + { + if (batches.RemoveAll(b => b.Id == id) == 0) return false; + loaded.Remove(id); + Publish(new Change("remove", new { id })); + return true; + } + } + /// /// The folder for a batch file at newFile. A relative folder (or any folder of an unsaved batch) becomes relative to newFile, with /. /// It stays absolute if no relative path is possible (another Windows drive). An absolute folder of a saved batch is your choice and stays. diff --git a/tests/SqlRunboard.Core.Tests/BatchFileTests.cs b/tests/SqlRunboard.Core.Tests/BatchFileTests.cs index bbf1fa8..de1874b 100644 --- a/tests/SqlRunboard.Core.Tests/BatchFileTests.cs +++ b/tests/SqlRunboard.Core.Tests/BatchFileTests.cs @@ -115,5 +115,83 @@ public void An_absolute_folder_stays_absolute_after_the_first_save() Assert.Equal(other, list.Save(b.Id, null, overwrite: false)!.Settings.Folder); } + [Fact] + public void Open_reads_the_settings_and_the_start_order_with_the_cleared_check_boxes() + { + Touch("scripts/a.sql", "scripts/b.sql", "scripts/c.sql"); + File.WriteAllText(Path.Combine(root, "deploy.sqlrunboard.json"), """ + { + "version": 1, + "folder": "scripts", + "parallelLimit": 2, + "target": { "server": "sql01", "auth": "SqlServer" }, + "scripts": [ + { "path": "c.sql", "include": true }, + { "path": "a.sql", "include": false }, + { "path": "b.sql", "include": true } + ] + } + """); + + var b = list.Open(Path.Combine(root, "deploy.sqlrunboard.json")); + + Assert.Equal("deploy", b.Name); + Assert.False(b.Dirty); + Assert.Equal(2, b.Settings.ParallelLimit); + Assert.Equal("*.sql", b.Settings.Filters); + Assert.Equal(Auth.SqlServer, b.Settings.Target.Auth); + Assert.Equal([new("c.sql", true), new("a.sql", false), new("b.sql", true)], b.Scripts); + } + + [Fact] + public void Opening_a_file_that_is_in_the_list_gives_the_existing_entry() + { + var saved = list.Save(list.NewBatch().Id, Path.Combine(root, "x"), overwrite: false)!; + + var opened = list.Open(Path.Combine(root, "sub", "..", "x.sqlrunboard.json")); + + Assert.Equal(saved.Id, opened.Id); + using var sub = list.Subscribe(); + Assert.Single(sub.Snapshot); + } + + [Fact] + public void Open_accepts_only_batch_files() + { + File.WriteAllText(Path.Combine(root, "x.json"), "{}"); + File.WriteAllText(Path.Combine(root, "bad.sqlrunboard.json"), """{ "version": 2 }"""); + + Assert.Throws(() => list.Open(Path.Combine(root, "x.json"))); + Assert.Throws(() => list.Open(Path.Combine(root, "bad.sqlrunboard.json"))); + Assert.Throws(() => list.Open(Path.Combine(root, "none.sqlrunboard.json"))); + } + + [Fact] + public void Load_again_replaces_the_batch_with_the_file_on_disk() + { + var b = list.Save(list.NewBatch().Id, Path.Combine(root, "x"), overwrite: false)!; + list.UpdateSettings(b.Id, b.Settings with { ParallelLimit = 9 }); + File.WriteAllText(b.File!, File.ReadAllText(b.File!).Replace("\"parallelLimit\": 4", "\"parallelLimit\": 7")); + + var reloaded = list.Reload(b.Id)!; + + Assert.Equal(7, reloaded.Settings.ParallelLimit); + Assert.False(reloaded.Dirty); + Assert.NotNull(list.Save(b.Id, null, overwrite: false)); // the loaded version is now the disk version + } + + [Fact] + public void Remove_takes_the_batch_off_the_list_and_keeps_the_file() + { + var b = list.Save(list.NewBatch().Id, Path.Combine(root, "x"), overwrite: false)!; + + Assert.True(list.Remove(b.Id)); + + using var sub = list.Subscribe(); + Assert.Empty(sub.Snapshot); + Assert.True(File.Exists(b.File)); + Assert.False(list.Remove(b.Id)); + } + public void Dispose() => Directory.Delete(root, recursive: true); } From 345d715e3c8d71e2e09d1a27e57cff22d9d43f23 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:44:02 +0200 Subject: [PATCH 22/96] feat(core): run a batch: rescan, queue the included scripts and obey the parallel limit --- src/SqlRunboard.Core/Batch.cs | 4 +- src/SqlRunboard.Core/BatchList.cs | 88 ++++++++++++- src/SqlRunboard.Core/Run.cs | 16 +++ tests/SqlRunboard.Core.Tests/RunTests.cs | 151 +++++++++++++++++++++++ 4 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 src/SqlRunboard.Core/Run.cs create mode 100644 tests/SqlRunboard.Core.Tests/RunTests.cs diff --git a/src/SqlRunboard.Core/Batch.cs b/src/SqlRunboard.Core/Batch.cs index bc0cb44..93350d9 100644 --- a/src/SqlRunboard.Core/Batch.cs +++ b/src/SqlRunboard.Core/Batch.cs @@ -33,5 +33,5 @@ public sealed record BatchSettings /// One file of the batch. Path is relative to the folder, with /. Include false: a cleared check box. public sealed record ScriptEntry(string Path, bool Include); -/// One entry in the side list. File is null for an unsaved batch. Scripts is the start order. -public sealed record Batch(int Id, string Name, string? File, bool Dirty, BatchSettings Settings, IReadOnlyList Scripts); +/// One entry in the side list. File is null for an unsaved batch. Scripts is the start order. Run is the current run, or null. +public sealed record Batch(int Id, string Name, string? File, bool Dirty, BatchSettings Settings, IReadOnlyList Scripts, RunState? Run = null); diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index 100f6c4..e01032d 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -8,11 +8,13 @@ public sealed record Change(string Kind, object Data); /// /// The side list: all batches, in the order you added them. The server keeps this state; the page shows it. /// Each subscriber gets the full state, then every change after it. +/// It also runs the batches: each batch has its own queue and parallel limit, and many batches can run at once. /// -public sealed class BatchList +public sealed class BatchList(ISqlExecutor executor, TimeProvider time) { readonly Lock gate = new(); readonly List batches = []; + readonly Dictionary runs = []; readonly List> subscribers = []; int lastId, lastUntitled; @@ -49,11 +51,95 @@ public Batch NewBatch() { var i = batches.FindIndex(b => b.Id == id); if (i < 0) return null; + if (batches[i].Run?.Active == true) return batches[i]; // the run locks the settings and the check boxes var batch = batches[i] = change(batches[i]) with { Dirty = true }; Publish(new Change("batch", batch)); return batch; } + /// + /// Scans the folder again and queues all included scripts in the start order. Returns the batch with its new run, + /// the batch unchanged if its run still has Queued or Running scripts, or null if no batch has this id. + /// + public Batch? Run(int id) + { + lock (gate) + { + var i = batches.FindIndex(b => b.Id == id); + if (i < 0) return null; + var b = batches[i]; + if (b.Run?.Active == true) return b; + var folder = FolderPath(b.File, b.Settings.Folder); + var scripts = Merge(b.Scripts, FileScan.Scan(folder, b.Settings.Filters, b.Settings.Recurse)); + batches[i] = b with { Scripts = scripts, Dirty = b.Dirty || !scripts.SequenceEqual(b.Scripts) }; + var run = runs[id] = new ActiveRun(time.GetUtcNow(), folder ?? "", b.Settings); + foreach (var s in scripts.Where(s => s.Include)) + { + run.Scripts[s.Path] = new ScriptRun(s.Path, ScriptStatus.Queued, []); + run.Queue.Add(s.Path); + } + Pump(id, run); + return batches[i]; + } + } + + /// The mutable state of a run. Change it only under the lock. + sealed class ActiveRun(DateTimeOffset start, string folder, BatchSettings settings) + { + public DateTimeOffset Start => start; + public string Folder => folder; + public BatchSettings Settings => settings; + public OrderedDictionary Scripts { get; } = new(StringComparer.Ordinal); // the start order + public List Queue { get; } = []; + public int Running { get; set; } + + public void Add(string path, Attempt a) { var s = Scripts[path]; Scripts[path] = s with { Status = a.Status, Attempts = [.. s.Attempts, a] }; } + public void SetLast(string path, Attempt a) { var s = Scripts[path]; Scripts[path] = s with { Status = a.Status, Attempts = [.. s.Attempts.SkipLast(1), a] }; } + } + + // Call under the lock. Starts Queued scripts up to the parallel limit, then publishes the run. + void Pump(int id, ActiveRun run) + { + while (run.Running < run.Settings.ParallelLimit && run.Queue.Count > 0) + { + var path = run.Queue[0]; + run.Queue.RemoveAt(0); + var start = time.GetUtcNow(); + string text; + // Each script reads its file when it starts, so you can fix a Queued script. + // ponytail: the read runs under the lock; move it out if slow network folders block the page. + try { text = File.ReadAllText(Path.Combine(run.Folder, path)); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + run.Add(path, new Attempt(start, start, ScriptStatus.Failed, null, e is FileNotFoundException or DirectoryNotFoundException ? "File not found" : e.Message)); + continue; + } + run.Running++; + run.Add(path, new Attempt(start, null, ScriptStatus.Running, null, null)); + var execution = new SqlExecution(path, [text], new SqlConnectionSettings(run.Settings.Target, Password: null)); + // The continuation runs on the thread pool, never inline here under the lock. + _ = executor.ExecuteAsync(execution, _ => { }, CancellationToken.None) + .ContinueWith(t => Finish(id, run, path, start, t), TaskScheduler.Default); + } + var i = batches.FindIndex(b => b.Id == id); + if (i < 0 || runs.GetValueOrDefault(id) != run) return; + var state = new RunState(run.Start, time.GetUtcNow(), [.. run.Scripts.Values], [.. run.Queue]); + Publish(new Change("batch", batches[i] = batches[i] with { Run = state })); + } + + void Finish(int id, ActiveRun run, string path, DateTimeOffset start, Task t) + { + lock (gate) + { + var end = time.GetUtcNow(); + run.SetLast(path, t.IsCompletedSuccessfully ? new Attempt(start, end, t.Result.Status, t.Result.Rows, t.Result.Error) + : t.IsCanceled ? new Attempt(start, end, ScriptStatus.Cancelled, null, null) + : new Attempt(start, end, ScriptStatus.Failed, null, t.Exception!.GetBaseException().Message)); + run.Running--; + Pump(id, run); + } + } + /// A relative folder is relative to the batch file. An unsaved batch has an absolute folder. static string? FolderPath(string? batchFile, string? folder) => string.IsNullOrWhiteSpace(folder) || batchFile is null ? folder : Path.Combine(Path.GetDirectoryName(batchFile)!, folder); diff --git a/src/SqlRunboard.Core/Run.cs b/src/SqlRunboard.Core/Run.cs new file mode 100644 index 0000000..fd39a5a --- /dev/null +++ b/src/SqlRunboard.Core/Run.cs @@ -0,0 +1,16 @@ +namespace SqlRunboard.Core; + +/// One attempt of one script. End is null while it runs. +public sealed record Attempt(DateTimeOffset Start, DateTimeOffset? End, ScriptStatus Status, long? Rows, string? Error); + +/// One script of a run. Status is the status of the last attempt, or Queued. +public sealed record ScriptRun(string Path, ScriptStatus Status, IReadOnlyList Attempts); + +/// +/// The current run of a batch, as the page sees it. Scripts are in the start order; Queue has the Queued paths in queue order. +/// Now is the server clock when this state was made, so the page can show the elapsed time of Running scripts. +/// +public sealed record RunState(DateTimeOffset Start, DateTimeOffset Now, IReadOnlyList Scripts, IReadOnlyList Queue) +{ + public bool Active => Scripts.Any(s => s.Status is ScriptStatus.Queued or ScriptStatus.Running); +} diff --git a/tests/SqlRunboard.Core.Tests/RunTests.cs b/tests/SqlRunboard.Core.Tests/RunTests.cs new file mode 100644 index 0000000..b5a1570 --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/RunTests.cs @@ -0,0 +1,151 @@ +using SqlRunboard.Core; + +/// The run engine, seen through BatchList: Run, then the published batch changes. Fake executor + fake clock. +public sealed class RunTests : IDisposable +{ + readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-run-").FullName; + readonly FakeTime time = new(); + readonly FakeSqlExecutor executor; + readonly BatchList list; + readonly BatchList.Subscription sub; + + public RunTests() + { + executor = new FakeSqlExecutor(time); + list = new BatchList(executor, time); + sub = list.Subscribe(); + } + + void Write(string path, string text = "select 1") => File.WriteAllText(Path.Combine(root, path), text); + + void Script(string path, int seconds, ScriptStatus status = ScriptStatus.Succeeded, long? rows = null) => + executor.Scripts[path] = new FakeScript { Duration = TimeSpan.FromSeconds(seconds), Status = status, Rows = rows }; + + Batch NewBatch(int limit, params string[] files) + { + foreach (var f in files) Write(f); + var b = list.NewBatch(); + return list.UpdateSettings(b.Id, new BatchSettings { Folder = root, ParallelLimit = limit })!; + } + + /// Waits for the next published state of the batch that matches. + async Task Until(int id, Func ok) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (true) + { + var change = await sub.Changes.ReadAsync(cts.Token); + if (change.Data is Batch { Run: { } run } b && b.Id == id && ok(run)) return run; + } + } + + static ScriptStatus StatusOf(RunState run, string path) => run.Scripts.Single(s => s.Path == path).Status; + + [Fact] + public async Task Run_queues_the_included_scripts_in_the_start_order_and_obeys_the_parallel_limit() + { + var b = NewBatch(2, "1.sql", "2.sql", "3.sql", "4.sql"); + list.SetInclude(b.Id, "2.sql", false); + Script("1.sql", 10, rows: 5); Script("3.sql", 20); Script("4.sql", 5); + + var run = list.Run(b.Id)!.Run!; + + Assert.Equal(["1.sql", "3.sql", "4.sql"], run.Scripts.Select(s => s.Path)); + Assert.Equal([ScriptStatus.Running, ScriptStatus.Running, ScriptStatus.Queued], run.Scripts.Select(s => s.Status)); + Assert.Equal(["4.sql"], run.Queue); + Assert.Equal(["1.sql", "3.sql"], executor.Calls.Select(c => c.ScriptName)); + + time.Advance(TimeSpan.FromSeconds(10)); + run = await Until(b.Id, r => StatusOf(r, "1.sql") == ScriptStatus.Succeeded); + Assert.Equal(ScriptStatus.Running, StatusOf(run, "4.sql")); + var a = Assert.Single(run.Scripts[0].Attempts); + Assert.Equal((time.GetUtcNow() - TimeSpan.FromSeconds(10), time.GetUtcNow(), 5L), (a.Start, a.End, a.Rows)); + + time.Advance(TimeSpan.FromSeconds(10)); + run = await Until(b.Id, r => r.Scripts.All(s => s.Status == ScriptStatus.Succeeded)); + Assert.Empty(run.Queue); + } + + [Fact] + public async Task A_limit_of_1_gives_an_ordered_run() + { + var b = NewBatch(1, "a.sql", "b.sql", "c.sql"); + Script("a.sql", 5); Script("b.sql", 5); Script("c.sql", 5); + + list.Run(b.Id); + for (var n = 0; n < 3; n++) + { + var run = await Until(b.Id, r => r.Scripts.Count(s => s.Status == ScriptStatus.Running) == 1 && r.Scripts.Count(s => s.Status == ScriptStatus.Succeeded) == n); + Assert.Equal(1, run.Scripts.Count(s => s.Status == ScriptStatus.Running)); + time.Advance(TimeSpan.FromSeconds(5)); + } + + await Until(b.Id, r => !r.Active); + Assert.Equal(["a.sql", "b.sql", "c.sql"], executor.Calls.Select(c => c.ScriptName)); + } + + [Fact] + public async Task Each_script_reads_its_file_when_it_starts_and_a_missing_file_fails() + { + var b = NewBatch(1, "a.sql", "b.sql", "c.sql"); + Script("a.sql", 5); + + list.Run(b.Id); + Write("b.sql", "select 'fixed'"); + File.Delete(Path.Combine(root, "c.sql")); + time.Advance(TimeSpan.FromSeconds(5)); + + var run = await Until(b.Id, r => !r.Active); + Assert.Equal(["select 'fixed'"], executor.Calls.Single(c => c.ScriptName == "b.sql").Commands); + Assert.Equal(ScriptStatus.Failed, StatusOf(run, "c.sql")); + Assert.Equal("File not found", run.Scripts.Single(s => s.Path == "c.sql").Attempts.Single().Error); + Assert.DoesNotContain(executor.Calls, c => c.ScriptName == "c.sql"); + } + + [Fact] + public void Run_scans_again_and_a_new_file_goes_at_the_bottom_with_its_check_box_set() + { + var b = NewBatch(4, "2.sql", "3.sql"); + list.SetInclude(b.Id, "3.sql", false); + Write("1.sql"); + + b = list.Run(b.Id)!; + + Assert.Equal([new ScriptEntry("2.sql", true), new ScriptEntry("3.sql", false), new ScriptEntry("1.sql", true)], b.Scripts); + Assert.Equal(["2.sql", "1.sql"], b.Run!.Scripts.Select(s => s.Path)); + } + + [Fact] + public async Task The_run_locks_the_settings_and_the_check_boxes_and_Run_until_it_ends() + { + var b = NewBatch(4, "a.sql"); + Script("a.sql", 5); + list.Run(b.Id); + + Assert.Equal(4, list.UpdateSettings(b.Id, b.Settings with { ParallelLimit = 8 })!.Settings.ParallelLimit); + Assert.True(list.SetInclude(b.Id, "a.sql", false)!.Scripts.Single().Include); + list.Run(b.Id); + Assert.Single(executor.Calls); + + time.Advance(TimeSpan.FromSeconds(5)); + await Until(b.Id, r => !r.Active); + Assert.Equal(8, list.UpdateSettings(b.Id, b.Settings with { ParallelLimit = 8 })!.Settings.ParallelLimit); + } + + [Fact] + public void Many_batches_run_at_the_same_time_each_with_its_own_limit() + { + var one = NewBatch(1, "a.sql", "b.sql"); + var two = NewBatch(2, "a.sql", "b.sql"); + Script("a.sql", 5); Script("b.sql", 5); + + Assert.Equal([ScriptStatus.Running, ScriptStatus.Queued], list.Run(one.Id)!.Run!.Scripts.Select(s => s.Status)); + Assert.Equal([ScriptStatus.Running, ScriptStatus.Running], list.Run(two.Id)!.Run!.Scripts.Select(s => s.Status)); + } + + public void Dispose() + { + sub.Dispose(); + Directory.Delete(root, recursive: true); + } +} From 008123ddba8120fec587d31f5f98244e3684c21d Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:46:22 +0200 Subject: [PATCH 23/96] feat(gui): run a batch from the header and show its scripts on the board --- src/SqlRunboard.Gui/GuiHost.cs | 2 + src/SqlRunboard.Gui/wwwroot/index.html | 25 +++++- tests/SqlRunboard.Gui.Tests/BoardTests.cs | 97 +++++++++++++++++++++++ 3 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 tests/SqlRunboard.Gui.Tests/BoardTests.cs diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 69a1cef..6fdd02c 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -69,6 +69,8 @@ public static async Task StartAsync(GuiHostOptions options) : list.UpdateSettings(id, settings) is { } b ? Results.Ok(b) : Results.NotFound()); app.MapPost("/api/batches/{id:int}/include", (int id, IncludeRequest r, BatchList list) => list.SetInclude(id, r.Path, r.Include) is { } b ? Results.Ok(b) : Results.NotFound()); + app.MapPost("/api/batches/{id:int}/run", (int id, BatchList list) => + list.Run(id) is { } b ? Results.Ok(b) : Results.NotFound()); // The browser has no folder picker that gives a path, so the page browses the folders here. app.MapPost("/api/folders", (FolderRequest r) => { diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 3979b30..869aa58 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -211,7 +211,16 @@ // ---------- server state ---------- // The server keeps all batch state. Each server batch merges into a page object that also keeps the view state (tab, selection, drawer). // The page uses the flat settings of the prototype; the server uses the batch file shape (target is nested). -const toView = sb => ({ ...sb, settings: { ...sb.settings, ...sb.settings.target, extra: sb.settings.target.extraConnectionString, timeoutSeconds: sb.settings.timeoutSeconds ?? '' } }); +const toView = sb => ({ ...sb, run: toRun(sb.run), settings: { ...sb.settings, ...sb.settings.target, extra: sb.settings.target.extraConnectionString, timeoutSeconds: sb.settings.timeoutSeconds ?? '' } }); +// The server run (ISO times) in the prototype shape: times are seconds from the start of the run. +// run.t is "now" on the server clock: the page clock plus the offset that the last state gave (the tests use a fake server clock). +function toRun(r) { + if (!r) return null; + const t0 = Date.parse(r.start), sec = x => x == null ? null : (Date.parse(x) - t0) / 1000, skew = Date.parse(r.now) - Date.now(), rs = {}; + for (const s of r.scripts) rs[s.path] = { status: s.status, msgs: [], attempts: s.attempts.map((a, i) => ({ n: i + 1, start: sec(a.start), end: sec(a.end), status: a.status, rows: a.rows, error: a.error })) }; + const ended = !r.scripts.some(s => s.status === 'Queued' || s.status === 'Running'); + return { rs, queue: r.queue, wall: new Date(t0), live: true, ended, endAt: ended ? Math.max(0, ...Object.values(rs).flatMap(x => x.attempts.map(a => a.end ?? 0))) : null, get t() { return (Date.now() + skew - t0) / 1000; } }; +} const fromView = s => ({ folder: s.folder || null, filters: s.filters, recurse: s.recurse, parallelLimit: +s.parallelLimit, stopOnFirstFailure: s.stopOnFirstFailure, confirmBeforeRun: s.confirmBeforeRun, timeoutSeconds: s.timeoutSeconds === '' ? null : +s.timeoutSeconds, target: { server: s.server, database: s.database, auth: s.auth, user: s.user, passwordVariable: s.passwordVariable, encrypt: s.encrypt, trustServerCertificate: s.trustServerCertificate, extraConnectionString: s.extra } }); @@ -257,7 +266,7 @@ // ponytail: stand-ins for the commands of later tickets. Each ticket replaces its names with a POST. const todo = () => toast('Not available yet.'); -const runBatch = todo, cancel = todo, cancelAll = todo, rerun = todo, move = todo, logon = todo, saveBatch = todo, openBatch = todo, removeBatch = todo; +const cancel = todo, cancelAll = todo, rerun = todo, move = todo, logon = todo, saveBatch = todo, openBatch = todo, removeBatch = todo; // Edits go to the server one at a time, in the order you make them. let edits = Promise.resolve(); const sendEdit = (url, body) => edits = edits.then(() => post(url, body)).catch(e => { toast(e.message); return null; }); @@ -265,6 +274,11 @@ b.saving = (b.saving ?? 0) + 1; sendEdit(`/api/batches/${b.id}/settings`, fromView(b.settings)).then(sb => { if (--b.saving) return; if (sb) upsert(sb); render(); }); } +async function runBatch(b) { + if (!b || b.missing || locked(b)) return; + const sb = await sendEdit(`/api/batches/${b.id}/run`, {}); + if (sb) { upsert(sb); b.tab = 'run'; render(); } +} function setInclude(b, s, include) { s.include = include; render(); sendEdit(`/api/batches/${b.id}/include`, { path: s.path, include }); } async function browse(b, path) { // the folder dialog: the server lists the folders try { openDialog({ kind: 'folder', b, ...await post('/api/folders', { path }) }); } catch { if (path) browse(b, null); else toast('The folder list is not available.'); } @@ -609,7 +623,11 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} function closeMenu(refocusBack = true) { const back = app.menu?.back; app.menu = null; document.getElementById('menu').innerHTML = ''; if (refocusBack) refocus(back); } // ---------- commands ---------- -function selectBatch(id) { app.cur = id; closeMenu(false); render(); } +function selectBatch(id) { // Files before the first run, Run after it + const b = app.batches.find(x => x.id === id); + if (b && id !== app.cur) b.tab = null; + app.cur = id; closeMenu(false); render(); +} async function newBatch() { // Ctrl+N let b; try { b = upsert(await post('/api/batches')); } catch (e) { return toast(e.message); } // the SSE event can come before or after this @@ -784,6 +802,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} setDrawerWidth(app.drawerWidth); render(); connect(); +setInterval(() => app.batches.some(b => b.run && !b.run.ended) && render({ tick: true }), 1000); // the elapsed time of Running scripts diff --git a/tests/SqlRunboard.Gui.Tests/BoardTests.cs b/tests/SqlRunboard.Gui.Tests/BoardTests.cs new file mode 100644 index 0000000..ab4968a --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/BoardTests.cs @@ -0,0 +1,97 @@ +using Microsoft.Playwright; +using SqlRunboard.Core; + +public class BoardTests : AppPageTest +{ + readonly string scripts = Directory.CreateTempSubdirectory("sqlrunboard-scripts-").FullName; + + ILocator Panel => Page.Locator("#panel"); + ILocator Header => Page.Locator("#hdr"); + ILocator Tab(string name) => Page.GetByRole(AriaRole.Tab, new() { Name = name }); + ILocator Side(string name) => Page.GetByRole(AriaRole.Navigation, new() { Name = "Batches" }).Locator($"[role=option]:has-text('{name}')"); + ILocator Column(string name) => Panel.GetByRole(AriaRole.Region, new() { Name = name, Exact = true }); + ILocator Cards(string column) => Column(column).Locator(".card .r1 span:not(.st)"); + + Batch NewBatch(int limit, params string[] files) + { + foreach (var f in files) File.WriteAllText(Path.Combine(scripts, f), "select 1"); + var b = App.Host.Batches.NewBatch(); + return App.Host.Batches.UpdateSettings(b.Id, new BatchSettings { Folder = scripts, ParallelLimit = limit })!; + } + + void Script(string path, int seconds, ScriptStatus status = ScriptStatus.Succeeded, long? rows = null) => + App.Executor.Scripts[path] = new FakeScript { Duration = TimeSpan.FromSeconds(seconds), Status = status, Rows = rows }; + + [Fact] + public async Task Run_shows_the_scripts_on_the_board_until_the_run_ends() + { + var b = NewBatch(2, "a.sql", "b.sql", "c.sql", "d.sql"); + Script("a.sql", 10, rows: 5); Script("b.sql", 20); Script("c.sql", 5, ScriptStatus.Failed); Script("d.sql", 1); + + await Side(b.Name).ClickAsync(); + await Expect(Tab("Files")).ToHaveAttributeAsync("aria-selected", "true"); + await Header.GetByRole(AriaRole.Button, new() { Name = "Run", Exact = true }).ClickAsync(); + + await Expect(Tab("Run")).ToHaveAttributeAsync("aria-selected", "true"); + await Expect(Cards("Running")).ToHaveTextAsync(["a.sql", "b.sql"]); + await Expect(Cards("Queued")).ToHaveTextAsync(["c.sql", "d.sql"]); + await Expect(Header).ToContainTextAsync(b.Name); + await Expect(Header).ToContainTextAsync("Parallel 2"); + await Expect(Header.GetByRole(AriaRole.Button, new() { Name = "Run", Exact = true })).ToBeDisabledAsync(); + await Expect(Header.Locator(".prog")).ToBeAttachedAsync(); + await Expect(Side(b.Name).Locator(".st")).ToHaveTextAsync("▶"); + + App.Time.Advance(TimeSpan.FromSeconds(10)); + await Expect(Cards("Succeeded")).ToHaveTextAsync(["a.sql"]); + await Expect(Column("Succeeded").Locator(".card .r2 span")).ToHaveTextAsync(["0:10", "attempt 1", "5 rows"]); + await Expect(Side(b.Name).Locator(".p")).ToHaveTextAsync("1/4"); + + App.Time.Advance(TimeSpan.FromSeconds(5)); + await Expect(Cards("Failed / Cancelled")).ToHaveTextAsync(["c.sql"]); + App.Time.Advance(TimeSpan.FromSeconds(1)); + await Expect(Cards("Succeeded")).ToHaveTextAsync(["d.sql", "a.sql"]); + App.Time.Advance(TimeSpan.FromSeconds(4)); + + await Expect(Cards("Succeeded")).ToHaveTextAsync(["b.sql", "d.sql", "a.sql"]); // the newest at the top + await Expect(Side(b.Name).Locator(".p")).ToHaveTextAsync("4/4, 1 failed"); + await Expect(Side(b.Name).Locator(".st")).ToHaveTextAsync("✕"); + await Expect(Header.GetByRole(AriaRole.Button, new() { Name = "Run", Exact = true })).ToBeEnabledAsync(); + } + + [Fact] + public async Task Selecting_a_batch_opens_Files_before_its_first_run_and_Run_after_it() + { + var ran = NewBatch(4, "a.sql"); + var fresh = NewBatch(4); + App.Host.Batches.Run(ran.Id); + + await Side(fresh.Name).ClickAsync(); + await Expect(Tab("Files")).ToHaveAttributeAsync("aria-selected", "true"); + await Side(ran.Name).ClickAsync(); + await Expect(Tab("Run")).ToHaveAttributeAsync("aria-selected", "true"); + await Expect(Side(ran.Name).Locator(".st")).ToHaveTextAsync("✓"); + } + + [Fact] + public async Task The_run_locks_the_settings_and_the_check_boxes() + { + var b = NewBatch(4, "a.sql"); + Script("a.sql", 60); + App.Host.Batches.Run(b.Id); + + await Side(b.Name).ClickAsync(); + await Tab("Settings").ClickAsync(); + await Expect(Panel.GetByLabel("Parallel limit")).ToBeDisabledAsync(); + await Tab("Files").ClickAsync(); + await Expect(Panel.GetByLabel("Include a.sql")).ToBeDisabledAsync(); + + App.Time.Advance(TimeSpan.FromSeconds(60)); + await Expect(Panel.GetByLabel("Include a.sql")).ToBeEnabledAsync(); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + Directory.Delete(scripts, recursive: true); + } +} From 75a1bf93d342b527f19c27099babdab8abb26191 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:49:59 +0200 Subject: [PATCH 24/96] feat(gui): save, save as, open and remove batch files with in-page dialogs --- src/SqlRunboard.Gui/GuiHost.cs | 40 ++++- src/SqlRunboard.Gui/wwwroot/index.html | 74 ++++++--- tests/SqlRunboard.Gui.Tests/BatchFileTests.cs | 140 ++++++++++++++++++ tests/SqlRunboard.Gui.Tests/TestApp.cs | 8 +- 4 files changed, 236 insertions(+), 26 deletions(-) create mode 100644 tests/SqlRunboard.Gui.Tests/BatchFileTests.cs diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index a1c927b..5b0a054 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Net; using System.Net.ServerSentEvents; using System.Runtime.CompilerServices; @@ -17,10 +18,15 @@ namespace SqlRunboard.Gui; /// Settings, lock file and logs of unsaved batches. Created if missing. -public sealed record GuiHostOptions(string AppDataFolder, ISqlExecutor Executor, TimeProvider Time); +/// Shows a folder in the OS file manager. Null: the OS default (tests pass a recorder). +public sealed record GuiHostOptions(string AppDataFolder, ISqlExecutor Executor, TimeProvider Time, Action? OpenFolder = null); public sealed record IncludeRequest(string Path, bool Include); -public sealed record FolderRequest(string? Path); +/// A file filter such as *.sqlrunboard.json: the answer then also lists those files. Null: folders only. +public sealed record FolderRequest(string? Path, string? Files = null); +public sealed record PathRequest(string Path); +/// Null: Save to the current file. Otherwise Save as. +public sealed record SaveRequest(string? Path, bool Overwrite); /// The local web server: Kestrel on 127.0.0.1, a random port, a random token. See docs/SPEC.md "GUI host". public sealed class GuiHost : IAsyncDisposable @@ -69,14 +75,26 @@ public static async Task StartAsync(GuiHostOptions options) : list.UpdateSettings(id, settings) is { } b ? Results.Ok(b) : Results.NotFound()); app.MapPost("/api/batches/{id:int}/include", (int id, IncludeRequest r, BatchList list) => list.SetInclude(id, r.Path, r.Include) is { } b ? Results.Ok(b) : Results.NotFound()); - // The browser has no folder picker that gives a path, so the page browses the folders here. + app.MapPost("/api/batches/open", (PathRequest r, BatchList list) => FileResult(() => list.Open(r.Path))); + app.MapPost("/api/batches/{id:int}/save", (int id, SaveRequest r, BatchList list) => FileResult(() => list.Save(id, r.Path, r.Overwrite))); + app.MapPost("/api/batches/{id:int}/reload", (int id, BatchList list) => FileResult(() => list.Reload(id))); + app.MapPost("/api/batches/{id:int}/remove", (int id, BatchList list) => list.Remove(id) ? Results.Ok(new { }) : Results.NotFound()); + app.MapPost("/api/app-data/open", (GuiHostOptions o) => + { + // UseShellExecute on a folder opens the file manager: Explorer, Finder or xdg-open. + (o.OpenFolder ?? (f => Process.Start(new ProcessStartInfo(f) { UseShellExecute = true })))(o.AppDataFolder); + return Results.Ok(new { }); + }); + // The browser has no folder or file picker that gives a path, so the page browses the folders here. app.MapPost("/api/folders", (FolderRequest r) => { var path = string.IsNullOrWhiteSpace(r.Path) ? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) : Path.GetFullPath(r.Path); + if (File.Exists(path)) path = Path.GetDirectoryName(path)!; // a batch file: start in its folder if (!Directory.Exists(path)) return Results.NotFound(); - var folders = Directory.EnumerateDirectories(path, "*", new EnumerationOptions { IgnoreInaccessible = true }) - .Order(FileScan.NaturalOrder).Select(d => new { name = Path.GetFileName(d), path = d }); - return Results.Ok(new { path, parent = Path.GetDirectoryName(path), folders }); + var options = new EnumerationOptions { IgnoreInaccessible = true, MatchType = MatchType.Simple }; + var folders = Directory.EnumerateDirectories(path, "*", options).Order(FileScan.NaturalOrder).Select(d => new { name = Path.GetFileName(d), path = d }); + var files = r.Files is null ? [] : Directory.EnumerateFiles(path, r.Files, options).Order(FileScan.NaturalOrder).Select(f => new { name = Path.GetFileName(f), path = f }); + return Results.Ok(new { path, parent = Path.GetDirectoryName(path), folders, files }); }); await app.StartAsync(); @@ -85,6 +103,16 @@ public static async Task StartAsync(GuiHostOptions options) return host; } + /// A batch file command: the batch, or a problem whose detail the page shows. 409: the file changed on disk. + static IResult FileResult(Func command) + { + try { return command() is { } b ? Results.Ok(b) : Results.NotFound(); } + catch (BatchFileChangedException e) { return Results.Problem(e.Message, statusCode: StatusCodes.Status409Conflict); } + catch (Exception e) when (e is FileNotFoundException or DirectoryNotFoundException) { return Results.Problem(e.Message, statusCode: StatusCodes.Status404NotFound); } + catch (Exception e) when (e is ArgumentException or InvalidDataException) { return Results.Problem(e.Message, statusCode: StatusCodes.Status400BadRequest); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return Results.Problem(e.Message, statusCode: StatusCodes.Status500InternalServerError); } + } + record LockFile(Uri Url, string Token); /// The start URL of the server in the lock file, or null when no server answers there with that token (a stale or missing lock file). diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 3979b30..8709d13 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -225,7 +225,9 @@ } async function post(url, body = {}) { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`); + if (!res.ok) { // a problem response has a detail text for the user + const e = new Error((await res.json().catch(() => null))?.detail ?? `${url}: HTTP ${res.status}`); e.status = res.status; throw e; + } return res.json(); } // SSE: the full state first ("snapshot"), then one event per change. EventSource sends the auth cookie and reconnects by itself. @@ -239,6 +241,7 @@ render(); }); es.addEventListener('batch', e => { upsert(JSON.parse(e.data)); render(); }); + es.addEventListener('remove', e => dropBatch(JSON.parse(e.data).id)); es.onerror = () => { delete document.documentElement.dataset.connected; }; } @@ -257,17 +260,43 @@ // ponytail: stand-ins for the commands of later tickets. Each ticket replaces its names with a POST. const todo = () => toast('Not available yet.'); -const runBatch = todo, cancel = todo, cancelAll = todo, rerun = todo, move = todo, logon = todo, saveBatch = todo, openBatch = todo, removeBatch = todo; -// Edits go to the server one at a time, in the order you make them. +const runBatch = todo, cancel = todo, cancelAll = todo, rerun = todo, move = todo, logon = todo; +// Edits go to the server one at a time, in the order you make them. queued() gives the error to the caller; sendEdit() shows it. let edits = Promise.resolve(); -const sendEdit = (url, body) => edits = edits.then(() => post(url, body)).catch(e => { toast(e.message); return null; }); +function queued(url, body) { const p = edits.then(() => post(url, body)); edits = p.catch(() => null); return p; } +const sendEdit = (url, body) => queued(url, body).catch(e => { toast(e.message); return null; }); function saveSettings(b) { b.saving = (b.saving ?? 0) + 1; sendEdit(`/api/batches/${b.id}/settings`, fromView(b.settings)).then(sb => { if (--b.saving) return; if (sb) upsert(sb); render(); }); } function setInclude(b, s, include) { s.include = include; render(); sendEdit(`/api/batches/${b.id}/include`, { path: s.path, include }); } -async function browse(b, path) { // the folder dialog: the server lists the folders - try { openDialog({ kind: 'folder', b, ...await post('/api/folders', { path }) }); } catch { if (path) browse(b, null); else toast('The folder list is not available.'); } +// The folder, Open and Save as dialogs: the server lists the folders (and the batch files). kind: folder, open or saveAs. +async function browse(b, path, kind = 'folder') { + const name = app.dialog?.kind === kind ? document.getElementById('dlg-in')?.value : undefined; // keep the typed file name + try { openDialog({ kind, b, name, ...await post('/api/folders', { path, files: kind === 'folder' ? null : '*' + EXT }) }); } + catch { if (path) browse(b, null, kind); else toast('The folder list is not available.'); } +} +// Batch files. The server reads and writes them; the page sends full paths. +const EXT = '.sqlrunboard.json'; +const isAbs = p => /^([a-zA-Z]:[\\/]|[\\/])/.test(p ?? ''); +const join = (dir, p) => isAbs(p) ? p : dir.replace(/[\\/]$/, '') + (dir.includes('\\') ? '\\' : '/') + p; +function saveBatch(b, path = null, overwrite = false) { // Ctrl+S; an unsaved batch does Save as + if (!b.file && !path) return saveAs(b); + queued(`/api/batches/${b.id}/save`, { path, overwrite }).then(sb => { upsert(sb); render(); announce(`Saved ${sb.name}.`); }, + e => e.status === 409 ? openDialog({ kind: 'changed', b, path }) : toast(e.message)); +} +const saveAs = b => b && !b.missing && browse(b, b.file ?? (isAbs(b.settings.folder) ? b.settings.folder : null), 'saveAs'); +const openBatch = () => browse(cur(), cur()?.file ?? null, 'open'); +async function openFile(path) { // the server gives the existing entry if the file is already in the list + try { const b = upsert(await post('/api/batches/open', { path })); app.cur = b.id; closeDialog(); } catch (e) { toast(e.message); } +} +function reloadBatch(b) { queued(`/api/batches/${b.id}/reload`).then(sb => { upsert(sb); render(); announce(`Loaded ${sb.name} again.`); }, e => toast(e.message)); } +function removeBatch(b) { if (b) sendEdit(`/api/batches/${b.id}/remove`).then(r => r && dropBatch(b.id)); } // the file stays on disk +function dropBatch(id) { + const i = app.batches.findIndex(b => b.id === id); if (i < 0) return; + app.batches.splice(i, 1); + if (app.cur === id) app.cur = (app.batches[i] ?? app.batches[i - 1])?.id ?? null; + render(); } const scriptText = () => null; // the file text comes with the Files tab function renderSource(lines, hl) { @@ -567,9 +596,14 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} confirm: () => `

Run ${esc(d.b.name)}?

The target is protected. Check it before the run.

Server
${esc(s.server)}
Database
${esc(s.database)}
Login
${esc(d.login)}
Scripts
${d.n}
`, - saveAs: () => `

Save as

-

The batch name is the file name without .sqlrunboard.json.

-
`, + saveAs: () => `

Save as

+

The batch name is the file name without ${EXT}. The app adds ${EXT} if you do not type it.

+ ${fileList(d)}
`, + open: () => `

Open a batch file

+

Type or paste a path, or select a ${EXT} file.

+ ${fileList(d)}
`, + changed: () => `

The file changed on disk

${esc(d.b.file)}

Another program changed the file after the app loaded it.

+
`, folder: () => `

Select a folder

${esc(d.path)}

${d.parent ? `` : ''}${d.folders.map(f => ``).join('')}
@@ -579,16 +613,17 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} el.innerHTML = `
`; (el.querySelector('#dlg-in') ?? el.querySelector('.primary')).focus(); } +const fileList = d => `

${esc(d.path)}

+ ${d.parent ? `` : ''}${d.folders.map(f => ``).join('')} + ${d.files.map(f => ``).join('')}
`; function dialogOk() { const d = app.dialog, v = document.getElementById('dlg-in')?.value ?? ''; if (d.kind === 'password') { if (!v) return; d.b.password = v; app.dialog = null; renderDialog(); logon(d.b, d.then); return; } if (d.kind === 'folder') { d.b.settings.folder = d.path; saveSettings(d.b); closeDialog(); return; } if (d.kind === 'confirm') { app.dialog = null; renderDialog(); d.then(); render(); return; } - if (d.kind === 'saveAs') { - if (!v.trim()) return; - const b = d.b; b.file = (b.file ? b.file.replace(/[^/]+$/, '') : '~/sql/') + v.trim(); - b.name = v.trim().replace(/\.sqlrunboard\.json$|\.json$/i, ''); saveBatch(b); closeDialog(); return; - } + if (d.kind === 'saveAs') { if (!v.trim()) return; closeDialog(); saveBatch(d.b, join(d.path, v.trim())); return; } + if (d.kind === 'open') { if (!v.trim()) return; const p = join(d.path, v.trim()); return p.toLowerCase().endsWith(EXT) ? openFile(p) : browse(d.b, p, 'open'); } + if (d.kind === 'changed') { closeDialog(); saveBatch(d.b, d.path, true); return; } closeDialog(); } function openMenu(b, path, x, y) { @@ -622,9 +657,9 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} case 'new': return newBatch(); case 'open': return openBatch(); case 'save': return b && !b.missing && saveBatch(b); - case 'saveAs': return todo(); + case 'saveAs': return saveAs(b); case 'remove': return removeBatch(b); - case 'appData': return todo(); + case 'appData': return post('/api/app-data/open').catch(e => toast(e.message)); case 'run': return runBatch(b); case 'cancelSignIn': b.signing = null; return render(); case 'cancelAll': cancelAll(b); return render(); @@ -643,8 +678,11 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} document.addEventListener('click', e => { if (app.menu && !e.target.closest('#menu')) closeMenu(false); const t = e.target, b = cur(); - const dlg = t.closest('[data-dlg]'); if (dlg) { e.preventDefault(); return dlg.dataset.dlg === 'ok' ? dialogOk() : closeDialog(); } - const fo = t.closest('[data-folder]'); if (fo) return browse(app.dialog.b, fo.dataset.folder); + const dlg = t.closest('[data-dlg]'); + if (dlg) { e.preventDefault(); const d = app.dialog; return dlg.dataset.dlg === 'ok' ? dialogOk() : (closeDialog(), dlg.dataset.dlg === 'reload' && reloadBatch(d.b)); } + const fo = t.closest('[data-folder]'); if (fo) return browse(app.dialog.b, fo.dataset.folder, app.dialog.kind); + const pk = t.closest('[data-pick]'); + if (pk) { if (app.dialog.kind === 'open') return openFile(pk.dataset.pick); document.getElementById('dlg-in').value = pk.textContent; return; } const mc = t.closest('[data-mcmd]'); if (mc) { const { b: mb, path } = app.menu, c = mc.dataset.mcmd; closeMenu(); diff --git a/tests/SqlRunboard.Gui.Tests/BatchFileTests.cs b/tests/SqlRunboard.Gui.Tests/BatchFileTests.cs new file mode 100644 index 0000000..16e1372 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/BatchFileTests.cs @@ -0,0 +1,140 @@ +using Microsoft.Playwright; + +/// Save, Save as, Open, Remove from list and Open app data folder in the browser, with the in-page dialogs. +public class BatchFileTests : AppPageTest +{ + readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-files-").FullName; + + ILocator Panel => Page.Locator("#panel"); + ILocator Dialog => Page.GetByRole(AriaRole.Dialog); + ILocator Side => Page.GetByRole(AriaRole.Navigation, new() { Name = "Batches" }).GetByRole(AriaRole.Option); + ILocator Top(string name) => Page.GetByRole(AriaRole.Toolbar, new() { Name = "App" }).GetByRole(AriaRole.Button, new() { Name = name }); + + async Task NewBatchWithFolderAsync(string folder) + { + await Page.Keyboard.PressAsync("ControlOrMeta+n"); + await Panel.GetByLabel("Folder", new() { Exact = true }).FillAsync(folder); + await Panel.GetByLabel("Folder", new() { Exact = true }).PressAsync("Enter"); + } + + string SaveOnServer(string name) + { + var b = App.Host.Batches.NewBatch(); + return App.Host.Batches.Save(b.Id, Path.Combine(root, name), overwrite: false)!.File!; + } + + [Fact] + public async Task Ctrl_S_on_an_unsaved_batch_does_Save_as_and_adds_the_extension() + { + Directory.CreateDirectory(Path.Combine(root, "scripts")); + await NewBatchWithFolderAsync(Path.Combine(root, "scripts")); + + await Page.Keyboard.PressAsync("ControlOrMeta+s"); + await Dialog.GetByLabel("File name").FillAsync(Path.Combine(root, "deploy")); + await Dialog.GetByLabel("File name").PressAsync("Enter"); + + await Expect(Dialog).ToHaveCountAsync(0); + await Expect(Page.Locator("#hdr h1")).ToHaveTextAsync("deploy"); + await Expect(Panel.GetByLabel("Folder", new() { Exact = true })).ToHaveValueAsync("scripts"); + Assert.Contains("\"folder\": \"scripts\"", File.ReadAllText(Path.Combine(root, "deploy.sqlrunboard.json"))); + } + + [Fact] + public async Task Save_as_saves_in_a_folder_selected_in_the_server_list() + { + Directory.CreateDirectory(Path.Combine(root, "out")); + await NewBatchWithFolderAsync(root); + + await Page.Keyboard.PressAsync("ControlOrMeta+Shift+s"); + await Dialog.GetByRole(AriaRole.Button, new() { Name = "out", Exact = true }).ClickAsync(); + await Expect(Dialog).ToContainTextAsync(Path.Combine(root, "out")); + await Dialog.GetByLabel("File name").FillAsync("nightly"); + await Dialog.GetByRole(AriaRole.Button, new() { Name = "Save", Exact = true }).ClickAsync(); + + await Expect(Dialog).ToHaveCountAsync(0); + await Expect(Page.Locator("#hdr h1")).ToHaveTextAsync("nightly"); + Assert.True(File.Exists(Path.Combine(root, "out", "nightly.sqlrunboard.json"))); + } + + [Fact] + public async Task Save_asks_to_overwrite_a_file_that_changed_on_disk() + { + var file = SaveOnServer("x"); + await Page.ReloadAsync(); + File.WriteAllText(file, File.ReadAllText(file).Replace("\"recurse\": false", "\"recurse\": true")); + + await Page.Keyboard.PressAsync("ControlOrMeta+s"); + await Dialog.GetByRole(AriaRole.Button, new() { Name = "Overwrite" }).ClickAsync(); + + await Expect(Dialog).ToHaveCountAsync(0); + await Expect(Page.Locator("#live")).ToHaveTextAsync("Saved x."); + Assert.Contains("\"recurse\": false", File.ReadAllText(file)); + } + + [Fact] + public async Task Save_can_load_a_changed_file_again() + { + var file = SaveOnServer("x"); + await Page.ReloadAsync(); + File.WriteAllText(file, File.ReadAllText(file).Replace("\"parallelLimit\": 4", "\"parallelLimit\": 7")); + + await Page.Keyboard.PressAsync("ControlOrMeta+s"); + await Dialog.GetByRole(AriaRole.Button, new() { Name = "Load again" }).ClickAsync(); + + await Page.GetByRole(AriaRole.Tab, new() { Name = "Settings" }).ClickAsync(); + await Expect(Panel.GetByLabel("Parallel limit")).ToHaveValueAsync("7"); + } + + [Fact] + public async Task Ctrl_O_lists_only_batch_files_and_opens_the_selected_one_once() + { + File.WriteAllText(Path.Combine(root, "notes.json"), "{}"); + await using (var other = await TestApp.StartAsync()) // write the file with another server, so this page has no entry yet + { + var b = other.Host.Batches.NewBatch(); + other.Host.Batches.Save(b.Id, Path.Combine(root, "deploy"), overwrite: false); + } + + for (var i = 0; i < 2; i++) + { + await Page.Keyboard.PressAsync("ControlOrMeta+o"); + await Dialog.GetByLabel("Path").FillAsync(root); + await Dialog.GetByLabel("Path").PressAsync("Enter"); + await Expect(Dialog.GetByRole(AriaRole.Button, new() { Name = "notes.json" })).ToHaveCountAsync(0); + await Dialog.GetByRole(AriaRole.Button, new() { Name = "deploy.sqlrunboard.json" }).ClickAsync(); + await Expect(Dialog).ToHaveCountAsync(0); + } + + await Expect(Side).ToHaveCountAsync(1); + await Expect(Side.First).ToHaveAttributeAsync("aria-selected", "true"); + await Expect(Page.Locator("#hdr h1")).ToHaveTextAsync("deploy"); + } + + [Fact] + public async Task Remove_from_list_keeps_the_file() + { + var file = SaveOnServer("x"); + await Page.ReloadAsync(); + await Expect(Side).ToHaveCountAsync(1); + + await Top("Remove from list").ClickAsync(); + + await Expect(Side).ToHaveCountAsync(0); + Assert.True(File.Exists(file)); + } + + [Fact] + public async Task Open_app_data_folder_opens_it() + { + await Top("Open app data folder").ClickAsync(); + + for (var i = 0; i < 50 && App.OpenedFolders.IsEmpty; i++) await Task.Delay(100, TestContext.Current.CancellationToken); + Assert.Equal([App.AppData], App.OpenedFolders); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + Directory.Delete(root, recursive: true); + } +} diff --git a/tests/SqlRunboard.Gui.Tests/TestApp.cs b/tests/SqlRunboard.Gui.Tests/TestApp.cs index 8c95477..72c36e6 100644 --- a/tests/SqlRunboard.Gui.Tests/TestApp.cs +++ b/tests/SqlRunboard.Gui.Tests/TestApp.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Net; using SqlRunboard.Core; using SqlRunboard.Gui; @@ -9,14 +10,17 @@ public sealed class TestApp : IAsyncDisposable public required FakeSqlExecutor Executor { get; init; } public required FakeTime Time { get; init; } public required string AppData { get; init; } + /// The folders that "Open app data folder" asked the OS to show. + public required ConcurrentQueue OpenedFolders { get; init; } public static async Task StartAsync() { var appData = Directory.CreateTempSubdirectory("sqlrunboard-test-").FullName; var time = new FakeTime(); var executor = new FakeSqlExecutor(time); - var host = await GuiHost.StartAsync(new GuiHostOptions(appData, executor, time)); - return new TestApp { Host = host, Executor = executor, Time = time, AppData = appData }; + var opened = new ConcurrentQueue(); + var host = await GuiHost.StartAsync(new GuiHostOptions(appData, executor, time, opened.Enqueue)); + return new TestApp { Host = host, Executor = executor, Time = time, AppData = appData, OpenedFolders = opened }; } /// An HTTP client that has the auth cookie, as the page has after the start URL. From e1fae33a3461e3d495f1a269fea362edd1adf55d Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:51:38 +0200 Subject: [PATCH 25/96] feat(gui): keep app settings in settings.json in the app data folder --- src/SqlRunboard.Gui/GuiHost.cs | 46 +++++++++++++- .../SqlRunboard.Gui.Tests/AppSettingsTests.cs | 61 +++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 tests/SqlRunboard.Gui.Tests/AppSettingsTests.cs diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 6fdd02c..bba469f 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -4,6 +4,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -52,6 +53,7 @@ public static async Task StartAsync(GuiHostOptions options) builder.Services.AddSingleton(options.Executor); builder.Services.AddSingleton(options.Time); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.ConfigureHttpJsonOptions(j => j.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); var app = builder.Build(); @@ -62,7 +64,13 @@ public static async Task StartAsync(GuiHostOptions options) app.UseStaticFiles(new StaticFileOptions { FileProvider = files }); // Server to page: SSE. Page to server: POST with a JSON body, one route per command. - app.MapGet("/events", (BatchList list, CancellationToken ct) => TypedResults.ServerSentEvents(Events(list, ct))); + app.MapGet("/events", (BatchList list, AppSettings settings, CancellationToken ct) => TypedResults.ServerSentEvents(Events(list, settings, ct))); + app.MapPost("/api/app-settings", (JsonNode? changes, AppSettings settings) => + { + if (changes is not JsonObject o) return Results.BadRequest(); + settings.Merge(o); + return Results.NoContent(); + }); app.MapPost("/api/batches", (BatchList list) => list.NewBatch()); app.MapPost("/api/batches/{id:int}/settings", (int id, BatchSettings settings, BatchList list) => settings is not { ParallelLimit: >= 1 and <= 64, TimeoutSeconds: null or >= 1, Filters: not null, Target: not null } ? Results.BadRequest() @@ -118,10 +126,10 @@ void WriteLock() JsonSerializer.Serialize(stream, new LockFile(BaseUrl, Token), JsonSerializerOptions.Web); } - static async IAsyncEnumerable> Events(BatchList list, [EnumeratorCancellation] CancellationToken ct) + static async IAsyncEnumerable> Events(BatchList list, AppSettings settings, [EnumeratorCancellation] CancellationToken ct) { using var sub = list.Subscribe(); - yield return new SseItem(new { batches = sub.Snapshot }, "snapshot"); + yield return new SseItem(new { batches = sub.Snapshot, app = settings.Snapshot() }, "snapshot"); await foreach (var change in sub.Changes.ReadAllAsync(ct)) yield return new SseItem(change.Data, change.Kind); } @@ -177,3 +185,35 @@ public async ValueTask DisposeAsync() await app.DisposeAsync(); } } + +/// +/// The app settings of the page (for example the last Run tab view), in settings.json in the app data folder. +/// The page owns the names and values. A POST changes only the names that it sends. The batch file does not keep them. +/// +public sealed class AppSettings(GuiHostOptions options) +{ + public const string FileName = "settings.json"; + readonly string path = Path.Combine(options.AppDataFolder, FileName); + readonly Lock gate = new(); + JsonObject? values; + + public JsonObject Snapshot() { lock (gate) return (JsonObject)Load().DeepClone(); } + + public void Merge(JsonObject changes) + { + lock (gate) + { + var v = Load(); + foreach (var (name, value) in changes) v[name] = value?.DeepClone(); + File.WriteAllText(path, v.ToJsonString()); + } + } + + JsonObject Load() + { + if (values is null) + try { values = JsonNode.Parse(File.ReadAllText(path)) as JsonObject; } + catch (Exception e) when (e is IOException or JsonException) { } // missing or bad file: start empty + return values ??= []; + } +} diff --git a/tests/SqlRunboard.Gui.Tests/AppSettingsTests.cs b/tests/SqlRunboard.Gui.Tests/AppSettingsTests.cs new file mode 100644 index 0000000..5de2aaa --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/AppSettingsTests.cs @@ -0,0 +1,61 @@ +using System.Net; +using System.Net.ServerSentEvents; +using System.Text; +using System.Text.Json; +using SqlRunboard.Gui; + +public class AppSettingsTests +{ + static readonly CancellationToken Ct = TestContext.Current.CancellationToken; + + static async Task SnapshotApp(HttpClient http) + { + await using var stream = await http.GetStreamAsync("/events", Ct); + await using var events = SseParser.Create(stream).EnumerateAsync(Ct).GetAsyncEnumerator(Ct); + Assert.True(await events.MoveNextAsync()); + return JsonDocument.Parse(events.Current.Data).RootElement.GetProperty("app"); + } + + [Fact] + public async Task The_app_settings_start_empty() + { + await using var app = await TestApp.StartAsync(); + using var http = app.Client(); + + Assert.Empty((await SnapshotApp(http)).EnumerateObject()); + } + + [Fact] + public async Task A_setting_is_kept_in_the_app_data_folder_after_a_restart() + { + await using var app = await TestApp.StartAsync(); + using (var http = app.Client()) + { + var post = await http.PostAsync("/api/app-settings", new StringContent("""{"view":"table"}""", Encoding.UTF8, "application/json"), Ct); + Assert.Equal(HttpStatusCode.NoContent, post.StatusCode); + await http.PostAsync("/api/app-settings", new StringContent("""{"theme":"Dark"}""", Encoding.UTF8, "application/json"), Ct); + } + Assert.True(File.Exists(Path.Combine(app.AppData, "settings.json"))); + + await using var again = await GuiHost.StartAsync(new GuiHostOptions(app.AppData, app.Executor, app.Time)); + var cookies = new CookieContainer(); + cookies.Add(again.BaseUrl, new Cookie(GuiHost.CookieName, again.Token)); + using var http2 = new HttpClient(new HttpClientHandler { CookieContainer = cookies }) { BaseAddress = again.BaseUrl }; + + var settings = await SnapshotApp(http2); + Assert.Equal("table", settings.GetProperty("view").GetString()); + Assert.Equal("Dark", settings.GetProperty("theme").GetString()); // a POST changes only the names it sends + } + + [Theory] + [InlineData("[]")] + [InlineData("\"table\"")] + public async Task The_server_accepts_only_a_JSON_object(string json) + { + await using var app = await TestApp.StartAsync(); + using var http = app.Client(); + + var post = await http.PostAsync("/api/app-settings", new StringContent(json, Encoding.UTF8, "application/json"), Ct); + Assert.Equal(HttpStatusCode.BadRequest, post.StatusCode); + } +} From 9348567db804a36ac12e0d4e31a15aebf612a466 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:51:57 +0200 Subject: [PATCH 26/96] feat(core): split script text on GO and GO n with the SSMS rules and keep each part's start line --- src/SqlRunboard.Core/SqlScript.cs | 68 +++++++++++++++++++ .../SqlRunboard.Core.Tests/SqlScriptTests.cs | 49 +++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 src/SqlRunboard.Core/SqlScript.cs create mode 100644 tests/SqlRunboard.Core.Tests/SqlScriptTests.cs diff --git a/src/SqlRunboard.Core/SqlScript.cs b/src/SqlRunboard.Core/SqlScript.cs new file mode 100644 index 0000000..721578d --- /dev/null +++ b/src/SqlRunboard.Core/SqlScript.cs @@ -0,0 +1,68 @@ +using System.Text.RegularExpressions; + +namespace SqlRunboard.Core; + +/// One GO part: the text to send, how many times to run it (GO n), and its first line in the file (1-based). +public sealed record ScriptPart(string Text, int Repeat, int Line); + +/// The script reader and GO splitter. +public static partial class SqlScript +{ + /// + /// Splits on lines that hold only GO or GO n (any case, spaces and a -- comment after it), as SSMS does. + /// GO inside a /* */ comment (nested too) or a '', "" or [] literal that continues across lines does not split. + /// + public static IReadOnlyList Split(string text) + { + var parts = new List(); + int start = 0, startLine = 1, line = 1, depth = 0; // depth: nested /* */ comments open at this point + var close = '\0'; // the quote that ends the open literal, or \0 + for (var pos = 0; pos <= text.Length; line++) + { + var end = text.IndexOf('\n', pos); + if (end < 0) end = text.Length; + var go = depth == 0 && close == '\0' ? GoLine().Match(text, pos, end - pos) : Match.Empty; + var repeat = 1; + if (go.Success && (!go.Groups[1].Success || int.TryParse(go.Groups[1].Value, out repeat) && repeat > 0)) + { + Add(text[start..pos], repeat); + start = Math.Min(end + 1, text.Length); + startLine = line + 1; + } + else Scan(text, pos, end, ref depth, ref close); + pos = end + 1; + } + Add(text[start..], 1); + return parts; + + void Add(string part, int repeat) + { + part = part.TrimEnd(); + if (part.Trim().Length > 0) parts.Add(new ScriptPart(part, repeat, startLine)); + } + } + + // Moves the comment and literal state over text[pos..end] (one line). + static void Scan(string text, int pos, int end, ref int depth, ref char close) + { + for (var i = pos; i < end; i++) + { + var c = text[i]; + var n = i + 1 < end ? text[i + 1] : '\0'; + if (close != '\0') + { + if (c != close) continue; + if (n == close) i++; // '' or ]] or "" is an escaped quote + else close = '\0'; + } + else if (c == '/' && n == '*') { depth++; i++; } + else if (depth > 0) { if (c == '*' && n == '/') { depth--; i++; } } + else if (c == '-' && n == '-') return; + else if (c is '\'' or '"') close = c; + else if (c == '[') close = ']'; + } + } + + [GeneratedRegex(@"^\s*GO(?:\s+(\d+))?\s*(?:--.*)?$", RegexOptions.IgnoreCase)] + private static partial Regex GoLine(); +} diff --git a/tests/SqlRunboard.Core.Tests/SqlScriptTests.cs b/tests/SqlRunboard.Core.Tests/SqlScriptTests.cs new file mode 100644 index 0000000..d4cbb26 --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/SqlScriptTests.cs @@ -0,0 +1,49 @@ +using SqlRunboard.Core; + +/// The script reader and GO splitter: file text in, parts with repeat count and start line out. +public class SqlScriptTests +{ + static ScriptPart[] Split(params string[] lines) => [.. SqlScript.Split(string.Join("\n", lines))]; + + [Fact] + public void Text_with_no_GO_is_one_part_that_starts_on_line_1() + { + Assert.Equal([new ScriptPart("select 1\nselect 2", 1, 1)], Split("select 1", "select 2")); + } + + [Fact] + public void GO_and_GO_n_alone_on_a_line_split_in_any_case_with_spaces_and_a_trailing_comment() + { + Assert.Equal( + [new ScriptPart("create table t (n int)", 1, 1), new ScriptPart("insert t values (1)", 3, 3), new ScriptPart("select * from t", 1, 5)], + Split("create table t (n int)", "GO", "insert t values (1)", " go 3 -- three rows", "select * from t", "Go\t", "")); + } + + [Fact] + public void GO_with_other_text_on_its_line_does_not_split() + { + string[] lines = ["select 1 GO", "GO select 2", "GO;", "GOTO done", "GO 2 3", "GO -1", "-- GO", "done:"]; + Assert.Equal([new ScriptPart(string.Join("\n", lines), 1, 1)], Split(lines)); + } + + [Fact] + public void GO_inside_a_block_comment_or_a_string_that_continues_across_lines_does_not_split() + { + string[] first = + [ + "/* outer /* nested */", "GO", "still a comment */", + "select 'it''s", "GO", "still a string', N'x', [col'umn], \"q'\"", + "-- a line comment with /* and ' does not open anything", + "select '--', '/*' /* closed */ -- end", + ]; + Assert.Equal( + [new ScriptPart(string.Join("\n", first), 1, 1), new ScriptPart("select 2", 1, 10)], + Split([.. first, "GO", "select 2"])); + } + + [Fact] + public void Blank_parts_are_dropped_and_a_part_keeps_its_leading_blank_lines_so_server_lines_map_to_file_lines() + { + Assert.Equal([new ScriptPart("\r\n\r\nselect 1", 5, 2)],[.. SqlScript.Split("GO\r\n\r\n\r\nselect 1\r\nGO 5\r\n \r\nGO\r\n")]); + } +} From 6bf0004ae57f76d60a3d1cffa28dbe91f15f91cf Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:52:50 +0200 Subject: [PATCH 27/96] feat(core): send each script as GO parts and read its file with BOM detection --- src/SqlRunboard.Core/BatchList.cs | 6 +++--- src/SqlRunboard.Core/SqlExecutor.cs | 7 +++++-- src/SqlRunboard.Core/SqlScript.cs | 3 +++ .../FakeSqlExecutorTests.cs | 8 ++++---- tests/SqlRunboard.Core.Tests/RunTests.cs | 20 ++++++++++++++++++- 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index e01032d..d26af7b 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -105,10 +105,10 @@ void Pump(int id, ActiveRun run) var path = run.Queue[0]; run.Queue.RemoveAt(0); var start = time.GetUtcNow(); - string text; + IReadOnlyList parts; // Each script reads its file when it starts, so you can fix a Queued script. // ponytail: the read runs under the lock; move it out if slow network folders block the page. - try { text = File.ReadAllText(Path.Combine(run.Folder, path)); } + try { parts = SqlScript.Read(Path.Combine(run.Folder, path)); } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { run.Add(path, new Attempt(start, start, ScriptStatus.Failed, null, e is FileNotFoundException or DirectoryNotFoundException ? "File not found" : e.Message)); @@ -116,7 +116,7 @@ void Pump(int id, ActiveRun run) } run.Running++; run.Add(path, new Attempt(start, null, ScriptStatus.Running, null, null)); - var execution = new SqlExecution(path, [text], new SqlConnectionSettings(run.Settings.Target, Password: null)); + var execution = new SqlExecution(path, parts, new SqlConnectionSettings(run.Settings.Target, Password: null)); // The continuation runs on the thread pool, never inline here under the lock. _ = executor.ExecuteAsync(execution, _ => { }, CancellationToken.None) .ContinueWith(t => Finish(id, run, path, start, t), TaskScheduler.Default); diff --git a/src/SqlRunboard.Core/SqlExecutor.cs b/src/SqlRunboard.Core/SqlExecutor.cs index 41e1aca..893feb4 100644 --- a/src/SqlRunboard.Core/SqlExecutor.cs +++ b/src/SqlRunboard.Core/SqlExecutor.cs @@ -9,8 +9,11 @@ public sealed record SqlMessage(SqlMessageKind Kind, string Text); /// The target of the batch plus the password from the page. The page never gets this. public sealed record SqlConnectionSettings(BatchTarget Target, string? Password); -/// One attempt of one script: the commands (the GO parts) to send on one new connection. -public sealed record SqlExecution(string ScriptName, IReadOnlyList Commands, SqlConnectionSettings Connection); +/// +/// One attempt of one script: the GO parts to send on one new connection, each part Repeat times, in order. +/// The executor stops at the first failed part, and shows its error line as "line N (part M)": N = part.Line + server line - 1, M = 1-based index. +/// +public sealed record SqlExecution(string ScriptName, IReadOnlyList Parts, SqlConnectionSettings Connection); /// Status is Succeeded or Failed. A cancel throws OperationCanceledException. public sealed record SqlResult(ScriptStatus Status, long? Rows, string? Error); diff --git a/src/SqlRunboard.Core/SqlScript.cs b/src/SqlRunboard.Core/SqlScript.cs index 721578d..6192c45 100644 --- a/src/SqlRunboard.Core/SqlScript.cs +++ b/src/SqlRunboard.Core/SqlScript.cs @@ -8,6 +8,9 @@ public sealed record ScriptPart(string Text, int Repeat, int Line); /// The script reader and GO splitter. public static partial class SqlScript { + /// Reads the file (a UTF-8 or UTF-16 BOM selects the encoding, else UTF-8) and splits it. + public static IReadOnlyList Read(string path) => Split(File.ReadAllText(path)); + /// /// Splits on lines that hold only GO or GO n (any case, spaces and a -- comment after it), as SSMS does. /// GO inside a /* */ comment (nested too) or a '', "" or [] literal that continues across lines does not split. diff --git a/tests/SqlRunboard.Core.Tests/FakeSqlExecutorTests.cs b/tests/SqlRunboard.Core.Tests/FakeSqlExecutorTests.cs index 4311aac..207bb2e 100644 --- a/tests/SqlRunboard.Core.Tests/FakeSqlExecutorTests.cs +++ b/tests/SqlRunboard.Core.Tests/FakeSqlExecutorTests.cs @@ -19,7 +19,7 @@ public async Task Script_runs_for_its_duration_on_the_clock_then_gives_its_resul }; var messages = new List(); - var run = executor.ExecuteAsync(new SqlExecution("005_fk.sql", ["SELECT 1", "SELECT 2"], Connection), messages.Add, CancellationToken.None); + var run = executor.ExecuteAsync(new SqlExecution("005_fk.sql", [new ScriptPart("SELECT 1", 1, 1), new ScriptPart("SELECT 2", 3, 2)], Connection), messages.Add, CancellationToken.None); time.Advance(TimeSpan.FromSeconds(29)); Assert.False(run.IsCompleted); time.Advance(TimeSpan.FromSeconds(1)); @@ -30,7 +30,7 @@ public async Task Script_runs_for_its_duration_on_the_clock_then_gives_its_resul [new SqlMessage(SqlMessageKind.Info, "Step 1 of 3 done"), new SqlMessage(SqlMessageKind.Error, "line 214 (part 3): FK conflict")], messages); var call = Assert.Single(executor.Calls); - Assert.Equal(["SELECT 1", "SELECT 2"], call.Commands); + Assert.Equal([new ScriptPart("SELECT 1", 1, 1), new ScriptPart("SELECT 2", 3, 2)], call.Parts); Assert.Equal("sql01", call.Connection.Target.Server); Assert.Equal("secret", call.Connection.Password); } @@ -40,7 +40,7 @@ public async Task Unknown_script_succeeds_at_once() { var executor = new FakeSqlExecutor(new FakeTime()); - var result = await executor.ExecuteAsync(new SqlExecution("x.sql", ["SELECT 1"], Connection), _ => { }, CancellationToken.None); + var result = await executor.ExecuteAsync(new SqlExecution("x.sql", [new ScriptPart("SELECT 1", 1, 1)], Connection), _ => { }, CancellationToken.None); Assert.Equal(new SqlResult(ScriptStatus.Succeeded, null, null), result); } @@ -52,7 +52,7 @@ public async Task Cancel_stops_a_running_script() executor.Scripts["slow.sql"] = new FakeScript { Duration = TimeSpan.FromMinutes(5) }; using var cts = new CancellationTokenSource(); - var run = executor.ExecuteAsync(new SqlExecution("slow.sql", ["SELECT 1"], Connection), _ => { }, cts.Token); + var run = executor.ExecuteAsync(new SqlExecution("slow.sql", [new ScriptPart("SELECT 1", 1, 1)], Connection), _ => { }, cts.Token); cts.Cancel(); await Assert.ThrowsAnyAsync(() => run); diff --git a/tests/SqlRunboard.Core.Tests/RunTests.cs b/tests/SqlRunboard.Core.Tests/RunTests.cs index b5a1570..f7753aa 100644 --- a/tests/SqlRunboard.Core.Tests/RunTests.cs +++ b/tests/SqlRunboard.Core.Tests/RunTests.cs @@ -84,6 +84,24 @@ public async Task A_limit_of_1_gives_an_ordered_run() Assert.Equal(["a.sql", "b.sql", "c.sql"], executor.Calls.Select(c => c.ScriptName)); } + [Fact] + public async Task A_script_is_sent_as_GO_parts_and_a_BOM_selects_the_encoding() + { + const string text = "select N'é'\r\nGO 2\r\nselect 2"; + var b = NewBatch(4); + File.WriteAllText(Path.Combine(root, "utf8.sql"), text, new System.Text.UTF8Encoding(false)); + File.WriteAllText(Path.Combine(root, "utf8bom.sql"), text, new System.Text.UTF8Encoding(true)); + File.WriteAllText(Path.Combine(root, "utf16le.sql"), text, new System.Text.UnicodeEncoding(false, true)); + File.WriteAllText(Path.Combine(root, "utf16be.sql"), text, new System.Text.UnicodeEncoding(true, true)); + + list.Run(b.Id); + await Until(b.Id, r => !r.Active); + + Assert.Equal(4, executor.Calls.Count); + foreach (var call in executor.Calls) + Assert.Equal([new ScriptPart("select N'é'", 2, 1), new ScriptPart("select 2", 1, 3)], call.Parts); + } + [Fact] public async Task Each_script_reads_its_file_when_it_starts_and_a_missing_file_fails() { @@ -96,7 +114,7 @@ public async Task Each_script_reads_its_file_when_it_starts_and_a_missing_file_f time.Advance(TimeSpan.FromSeconds(5)); var run = await Until(b.Id, r => !r.Active); - Assert.Equal(["select 'fixed'"], executor.Calls.Single(c => c.ScriptName == "b.sql").Commands); + Assert.Equal([new ScriptPart("select 'fixed'", 1, 1)], executor.Calls.Single(c => c.ScriptName == "b.sql").Parts); Assert.Equal(ScriptStatus.Failed, StatusOf(run, "c.sql")); Assert.Equal("File not found", run.Scripts.Single(s => s.Path == "c.sql").Attempts.Single().Error); Assert.DoesNotContain(executor.Calls, c => c.ScriptName == "c.sql"); From 11b2ebb216055c4c9430032485f928189ff2b114 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:53:37 +0200 Subject: [PATCH 28/96] feat(core): cancel one or all scripts, fail on the command timeout and stop the queue at a failure --- src/SqlRunboard.Core/BatchList.cs | 51 +++++++++++++++- src/SqlRunboard.Core/Run.cs | 4 +- tests/SqlRunboard.Core.Tests/RunTests.cs | 76 ++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index e01032d..30d213f 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -83,6 +83,36 @@ public Batch NewBatch() } } + /// + /// Cancels one script of the current run. A Queued script changes to Cancelled with 0 attempts. A Running script + /// changes to Cancelled when its executor stops. Returns the batch, or null if no batch has this id. + /// + public Batch? Cancel(int id, string path) => Cancel(id, _ => [path]); + + /// Cancels all Running and Queued scripts of the current run. Null if no batch has this id. + public Batch? CancelAll(int id) => Cancel(id, run => [.. run.Queue, .. run.Tokens.Keys]); + + Batch? Cancel(int id, Func> paths) + { + List stop = []; + Batch? batch; + lock (gate) + { + if (runs.GetValueOrDefault(id) is { } run) + { + foreach (var path in paths(run)) + if (run.Queue.Remove(path)) run.Scripts[path] = run.Scripts[path] with { Status = ScriptStatus.Cancelled }; + else if (run.Tokens.TryGetValue(path, out var cts) && run.Cancelled.Add(path)) stop.Add(cts); + Pump(id, run); + } + batch = Find(id); + } + // Outside the lock: a real executor can block while it tells the server to stop. + foreach (var cts in stop) + try { cts.Cancel(); } catch (ObjectDisposedException) { } // the script ended first + return batch; + } + /// The mutable state of a run. Change it only under the lock. sealed class ActiveRun(DateTimeOffset start, string folder, BatchSettings settings) { @@ -92,8 +122,17 @@ sealed class ActiveRun(DateTimeOffset start, string folder, BatchSettings settin public OrderedDictionary Scripts { get; } = new(StringComparer.Ordinal); // the start order public List Queue { get; } = []; public int Running { get; set; } + public HashSet Cancelled { get; } = new(StringComparer.Ordinal); // Running scripts that you cancelled: not a timeout + public Dictionary Tokens { get; } = new(StringComparer.Ordinal); // one per Running script public void Add(string path, Attempt a) { var s = Scripts[path]; Scripts[path] = s with { Status = a.Status, Attempts = [.. s.Attempts, a] }; } + /// "Stop the queue at the first failure": no new scripts start. The Running scripts continue. + public void Failed() + { + if (!settings.StopOnFirstFailure) return; + foreach (var path in Queue) Scripts[path] = Scripts[path] with { Status = ScriptStatus.Cancelled, Error = "Not started: the queue stopped" }; + Queue.Clear(); + } public void SetLast(string path, Attempt a) { var s = Scripts[path]; Scripts[path] = s with { Status = a.Status, Attempts = [.. s.Attempts.SkipLast(1), a] }; } } @@ -112,13 +151,15 @@ void Pump(int id, ActiveRun run) catch (Exception e) when (e is IOException or UnauthorizedAccessException) { run.Add(path, new Attempt(start, start, ScriptStatus.Failed, null, e is FileNotFoundException or DirectoryNotFoundException ? "File not found" : e.Message)); + run.Failed(); continue; } run.Running++; run.Add(path, new Attempt(start, null, ScriptStatus.Running, null, null)); var execution = new SqlExecution(path, [text], new SqlConnectionSettings(run.Settings.Target, Password: null)); // The continuation runs on the thread pool, never inline here under the lock. - _ = executor.ExecuteAsync(execution, _ => { }, CancellationToken.None) + var cts = run.Tokens[path] = run.Settings.TimeoutSeconds is int s ? new CancellationTokenSource(TimeSpan.FromSeconds(s), time) : new CancellationTokenSource(); + _ = executor.ExecuteAsync(execution, _ => { }, cts.Token) .ContinueWith(t => Finish(id, run, path, start, t), TaskScheduler.Default); } var i = batches.FindIndex(b => b.Id == id); @@ -132,14 +173,20 @@ void Finish(int id, ActiveRun run, string path, DateTimeOffset start, Task batches.Find(b => b.Id == id); + /// A relative folder is relative to the batch file. An unsaved batch has an absolute folder. static string? FolderPath(string? batchFile, string? folder) => string.IsNullOrWhiteSpace(folder) || batchFile is null ? folder : Path.Combine(Path.GetDirectoryName(batchFile)!, folder); diff --git a/src/SqlRunboard.Core/Run.cs b/src/SqlRunboard.Core/Run.cs index fd39a5a..1902c50 100644 --- a/src/SqlRunboard.Core/Run.cs +++ b/src/SqlRunboard.Core/Run.cs @@ -3,8 +3,8 @@ namespace SqlRunboard.Core; /// One attempt of one script. End is null while it runs. public sealed record Attempt(DateTimeOffset Start, DateTimeOffset? End, ScriptStatus Status, long? Rows, string? Error); -/// One script of a run. Status is the status of the last attempt, or Queued. -public sealed record ScriptRun(string Path, ScriptStatus Status, IReadOnlyList Attempts); +/// One script of a run. Status is the status of the last attempt, or Queued. Error: why a script with 0 attempts did not start. +public sealed record ScriptRun(string Path, ScriptStatus Status, IReadOnlyList Attempts, string? Error = null); /// /// The current run of a batch, as the page sees it. Scripts are in the start order; Queue has the Queued paths in queue order. diff --git a/tests/SqlRunboard.Core.Tests/RunTests.cs b/tests/SqlRunboard.Core.Tests/RunTests.cs index b5a1570..8b30a2d 100644 --- a/tests/SqlRunboard.Core.Tests/RunTests.cs +++ b/tests/SqlRunboard.Core.Tests/RunTests.cs @@ -143,6 +143,82 @@ public void Many_batches_run_at_the_same_time_each_with_its_own_limit() Assert.Equal([ScriptStatus.Running, ScriptStatus.Running], list.Run(two.Id)!.Run!.Scripts.Select(s => s.Status)); } + [Fact] + public async Task Cancel_stops_a_Running_script_and_a_Queued_script_never_starts() + { + var b = NewBatch(1, "a.sql", "b.sql", "c.sql"); + Script("a.sql", 60); Script("b.sql", 5); Script("c.sql", 5); + list.Run(b.Id); + + var run = list.Cancel(b.Id, "b.sql")!.Run!; + Assert.Equal(ScriptStatus.Cancelled, StatusOf(run, "b.sql")); + Assert.Empty(run.Scripts.Single(s => s.Path == "b.sql").Attempts); + Assert.Equal(["c.sql"], run.Queue); + + list.Cancel(b.Id, "a.sql"); + run = await Until(b.Id, r => StatusOf(r, "a.sql") == ScriptStatus.Cancelled); + Assert.Equal(ScriptStatus.Cancelled, run.Scripts[0].Attempts.Single().Status); + Assert.Equal(ScriptStatus.Running, StatusOf(run, "c.sql")); // the others continue + Assert.DoesNotContain(executor.Calls, c => c.ScriptName == "b.sql"); + } + + [Fact] + public async Task Cancel_all_cancels_the_Running_and_Queued_scripts_and_ends_the_run() + { + var b = NewBatch(2, "a.sql", "b.sql", "c.sql"); + Script("a.sql", 60); Script("b.sql", 60); + list.Run(b.Id); + + list.CancelAll(b.Id); + + var run = await Until(b.Id, r => !r.Active); + Assert.All(run.Scripts, s => Assert.Equal(ScriptStatus.Cancelled, s.Status)); + Assert.Equal([1, 1, 0], run.Scripts.Select(s => s.Attempts.Count)); + Assert.Equal(2, executor.Calls.Count); + } + + [Fact] + public async Task A_script_that_reaches_the_timeout_fails_with_Timeout_after_N_s() + { + var b = NewBatch(4, "a.sql", "b.sql"); + list.UpdateSettings(b.Id, b.Settings with { TimeoutSeconds = 30 }); + Script("a.sql", 60); Script("b.sql", 20); + list.Run(b.Id); + + time.Advance(TimeSpan.FromSeconds(30)); + + var run = await Until(b.Id, r => !r.Active); + var a = run.Scripts[0].Attempts.Single(); + Assert.Equal((ScriptStatus.Failed, "Timeout after 30 s"), (a.Status, a.Error)); + Assert.Equal(ScriptStatus.Succeeded, StatusOf(run, "b.sql")); + } + + [Theory] + [InlineData("fails")] + [InlineData("is missing")] + public async Task Stop_the_queue_at_the_first_failure_cancels_the_Queued_scripts_and_the_Running_ones_continue(string how) + { + var b = NewBatch(2, "a.sql", "b.sql", "c.sql", "d.sql"); + list.UpdateSettings(b.Id, b.Settings with { StopOnFirstFailure = true }); + Script("a.sql", 5, ScriptStatus.Failed); Script("b.sql", 30); + if (how == "is missing") Script("a.sql", 5); + + list.Run(b.Id); + if (how == "is missing") File.Delete(Path.Combine(root, "c.sql")); + time.Advance(TimeSpan.FromSeconds(10)); + + var run = await Until(b.Id, r => !r.Queue.Contains("d.sql")); + Assert.Equal(ScriptStatus.Running, StatusOf(run, "b.sql")); + var d = run.Scripts.Single(s => s.Path == "d.sql"); + Assert.Equal((ScriptStatus.Cancelled, "Not started: the queue stopped"), (d.Status, d.Error)); + Assert.Empty(d.Attempts); + + time.Advance(TimeSpan.FromSeconds(20)); + run = await Until(b.Id, r => !r.Active); + Assert.Equal(ScriptStatus.Succeeded, StatusOf(run, "b.sql")); + Assert.DoesNotContain(executor.Calls, c => c.ScriptName == "d.sql"); + } + public void Dispose() { sub.Dispose(); From abb67aeac40bfb1763b6643585b8b35af15e98ff Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:55:26 +0200 Subject: [PATCH 29/96] test(gui): wait for the SSE snapshot before the batch file tests press keys --- tests/SqlRunboard.Gui.Tests/BatchFileTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/SqlRunboard.Gui.Tests/BatchFileTests.cs b/tests/SqlRunboard.Gui.Tests/BatchFileTests.cs index 16e1372..52e6611 100644 --- a/tests/SqlRunboard.Gui.Tests/BatchFileTests.cs +++ b/tests/SqlRunboard.Gui.Tests/BatchFileTests.cs @@ -60,7 +60,7 @@ public async Task Save_as_saves_in_a_folder_selected_in_the_server_list() public async Task Save_asks_to_overwrite_a_file_that_changed_on_disk() { var file = SaveOnServer("x"); - await Page.ReloadAsync(); + await OpenAsync(Page, App.Host.BaseUrl); // the page gets the batch that the test saved on the server File.WriteAllText(file, File.ReadAllText(file).Replace("\"recurse\": false", "\"recurse\": true")); await Page.Keyboard.PressAsync("ControlOrMeta+s"); @@ -75,7 +75,7 @@ public async Task Save_asks_to_overwrite_a_file_that_changed_on_disk() public async Task Save_can_load_a_changed_file_again() { var file = SaveOnServer("x"); - await Page.ReloadAsync(); + await OpenAsync(Page, App.Host.BaseUrl); // the page gets the batch that the test saved on the server File.WriteAllText(file, File.ReadAllText(file).Replace("\"parallelLimit\": 4", "\"parallelLimit\": 7")); await Page.Keyboard.PressAsync("ControlOrMeta+s"); @@ -114,7 +114,7 @@ public async Task Ctrl_O_lists_only_batch_files_and_opens_the_selected_one_once( public async Task Remove_from_list_keeps_the_file() { var file = SaveOnServer("x"); - await Page.ReloadAsync(); + await OpenAsync(Page, App.Host.BaseUrl); // the page gets the batch that the test saved on the server await Expect(Side).ToHaveCountAsync(1); await Top("Remove from list").ClickAsync(); From e839d2c3701b58627dd12d6340f4aa00efd1da33 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:55:32 +0200 Subject: [PATCH 30/96] feat(gui): cancel a script from its card menu or drawer and all scripts from the header --- src/SqlRunboard.Gui/GuiHost.cs | 5 ++++ src/SqlRunboard.Gui/wwwroot/index.html | 7 +++-- tests/SqlRunboard.Gui.Tests/BoardTests.cs | 34 +++++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 6fdd02c..205242a 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -21,6 +21,7 @@ public sealed record GuiHostOptions(string AppDataFolder, ISqlExecutor Executor, public sealed record IncludeRequest(string Path, bool Include); public sealed record FolderRequest(string? Path); +public sealed record ScriptRequest(string Path); /// The local web server: Kestrel on 127.0.0.1, a random port, a random token. See docs/SPEC.md "GUI host". public sealed class GuiHost : IAsyncDisposable @@ -71,6 +72,10 @@ public static async Task StartAsync(GuiHostOptions options) list.SetInclude(id, r.Path, r.Include) is { } b ? Results.Ok(b) : Results.NotFound()); app.MapPost("/api/batches/{id:int}/run", (int id, BatchList list) => list.Run(id) is { } b ? Results.Ok(b) : Results.NotFound()); + app.MapPost("/api/batches/{id:int}/cancel", (int id, ScriptRequest r, BatchList list) => + list.Cancel(id, r.Path) is { } b ? Results.Ok(b) : Results.NotFound()); + app.MapPost("/api/batches/{id:int}/cancel-all", (int id, BatchList list) => + list.CancelAll(id) is { } b ? Results.Ok(b) : Results.NotFound()); // The browser has no folder picker that gives a path, so the page browses the folders here. app.MapPost("/api/folders", (FolderRequest r) => { diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 869aa58..e96755c 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -217,7 +217,7 @@ function toRun(r) { if (!r) return null; const t0 = Date.parse(r.start), sec = x => x == null ? null : (Date.parse(x) - t0) / 1000, skew = Date.parse(r.now) - Date.now(), rs = {}; - for (const s of r.scripts) rs[s.path] = { status: s.status, msgs: [], attempts: s.attempts.map((a, i) => ({ n: i + 1, start: sec(a.start), end: sec(a.end), status: a.status, rows: a.rows, error: a.error })) }; + for (const s of r.scripts) rs[s.path] = { status: s.status, error: s.error, msgs: [], attempts: s.attempts.map((a, i) => ({ n: i + 1, start: sec(a.start), end: sec(a.end), status: a.status, rows: a.rows, error: a.error })) }; const ended = !r.scripts.some(s => s.status === 'Queued' || s.status === 'Running'); return { rs, queue: r.queue, wall: new Date(t0), live: true, ended, endAt: ended ? Math.max(0, ...Object.values(rs).flatMap(x => x.attempts.map(a => a.end ?? 0))) : null, get t() { return (Date.now() + skew - t0) / 1000; } }; } @@ -266,7 +266,7 @@ // ponytail: stand-ins for the commands of later tickets. Each ticket replaces its names with a POST. const todo = () => toast('Not available yet.'); -const cancel = todo, cancelAll = todo, rerun = todo, move = todo, logon = todo, saveBatch = todo, openBatch = todo, removeBatch = todo; +const rerun = todo, move = todo, logon = todo, saveBatch = todo, openBatch = todo, removeBatch = todo; // Edits go to the server one at a time, in the order you make them. let edits = Promise.resolve(); const sendEdit = (url, body) => edits = edits.then(() => post(url, body)).catch(e => { toast(e.message); return null; }); @@ -279,6 +279,8 @@ const sb = await sendEdit(`/api/batches/${b.id}/run`, {}); if (sb) { upsert(sb); b.tab = 'run'; render(); } } +const cancel = (b, path) => sendEdit(`/api/batches/${b.id}/cancel`, { path }); +const cancelAll = b => sendEdit(`/api/batches/${b.id}/cancel-all`, {}); function setInclude(b, s, include) { s.include = include; render(); sendEdit(`/api/batches/${b.id}/include`, { path: s.path, include }); } async function browse(b, path) { // the folder dialog: the server lists the folders try { openDialog({ kind: 'folder', b, ...await post('/api/folders', { path }) }); } catch { if (path) browse(b, null); else toast('The folder list is not available.'); } @@ -511,6 +513,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} const r = x.r, a = lastA(r), live = b.run && !b.run.ended, mv = canMove(b, x.path); let body = ''; if (r.virtual) body = '

No run yet.

'; + else if (!r.attempts.length && r.error) body = `
${esc(r.error)}
`; // did not start for (const at of r.attempts) { body += `
Attempt ${at.n}${at.start != null ? ` · ${clock(b.run, at.start)}` : ''} · ${at.status}
`; if (at.restored) body += `${at.error ? `
${lineLink(at.error)}
` : ''}
Full messages: run.log
`; diff --git a/tests/SqlRunboard.Gui.Tests/BoardTests.cs b/tests/SqlRunboard.Gui.Tests/BoardTests.cs index ab4968a..89a58c0 100644 --- a/tests/SqlRunboard.Gui.Tests/BoardTests.cs +++ b/tests/SqlRunboard.Gui.Tests/BoardTests.cs @@ -89,6 +89,40 @@ public async Task The_run_locks_the_settings_and_the_check_boxes() await Expect(Panel.GetByLabel("Include a.sql")).ToBeEnabledAsync(); } + [Fact] + public async Task Cancel_a_script_from_its_card_menu_and_the_rest_with_Cancel_all() + { + var b = NewBatch(1, "a.sql", "b.sql", "c.sql"); + Script("a.sql", 60); + App.Host.Batches.Run(b.Id); + await Side(b.Name).ClickAsync(); + + await Column("Queued").Locator(".card:has-text('b.sql')").ClickAsync(new() { Button = MouseButton.Right }); + await Page.GetByRole(AriaRole.Menuitem, new() { Name = "Cancel" }).ClickAsync(); + await Expect(Cards("Failed / Cancelled")).ToHaveTextAsync(["b.sql"]); + await Expect(Column("Failed / Cancelled").Locator(".card .r2")).ToContainTextAsync("Cancelled"); + + await Header.GetByRole(AriaRole.Button, new() { Name = "Cancel all" }).ClickAsync(); + await Expect(Cards("Failed / Cancelled")).ToHaveCountAsync(3); + await Expect(Cards("Running")).ToHaveCountAsync(0); + await Expect(Header.GetByRole(AriaRole.Button, new() { Name = "Run", Exact = true })).ToBeEnabledAsync(); + } + + [Fact] + public async Task The_drawer_of_a_script_that_did_not_start_says_why() + { + var b = NewBatch(1, "a.sql", "b.sql"); + App.Host.Batches.UpdateSettings(b.Id, b.Settings with { StopOnFirstFailure = true }); + Script("a.sql", 5, ScriptStatus.Failed); + App.Host.Batches.Run(b.Id); + await Side(b.Name).ClickAsync(); + + App.Time.Advance(TimeSpan.FromSeconds(5)); + await Expect(Cards("Failed / Cancelled")).ToHaveTextAsync(["a.sql", "b.sql"]); // a script with 0 attempts has no end time: it goes at the bottom + await Column("Failed / Cancelled").Locator(".card:has-text('b.sql')").ClickAsync(); + await Expect(Page.Locator("#drawer .msgs")).ToContainTextAsync("Not started: the queue stopped"); + } + public override async ValueTask DisposeAsync() { await base.DisposeAsync(); From dce6e5a00533fd3e53dcbc52bdb3a9a42b131588 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:57:19 +0200 Subject: [PATCH 31/96] fix(core): stop the queue of a batch that you remove from the list --- src/SqlRunboard.Core/BatchList.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index 9d8478d..6fe16dc 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -122,6 +122,8 @@ public bool Remove(int id) { if (batches.RemoveAll(b => b.Id == id) == 0) return false; loaded.Remove(id); + // ponytail: Running scripts of a removed batch finish on their own; only the Queued ones stop. Cancel them when #8 adds cancel. + if (runs.Remove(id, out var run)) run.Queue.Clear(); Publish(new Change("remove", new { id })); return true; } From da5144b4d5905bc4b921b1acd15e51f656d46d3e Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:58:58 +0200 Subject: [PATCH 32/96] feat(gui): keep the Run tab view and other page settings in the app settings --- src/SqlRunboard.Gui/wwwroot/index.html | 14 +-- tests/SqlRunboard.Gui.Tests/RunViewTests.cs | 100 ++++++++++++++++++++ 2 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 tests/SqlRunboard.Gui.Tests/RunViewTests.cs diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 869aa58..b0a6e73 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -202,12 +202,10 @@ const isMac = /Mac|iPhone|iPad/.test(navigator.platform); const MOD = isMac ? '⌘' : 'Ctrl+'; -// ---------- settings.json stand-in ---------- -const store = { - get(k, d) { try { const v = localStorage.getItem('sqlrunboard-proto:' + k); return v == null ? d : JSON.parse(v); } catch { return d; } }, - set(k, v) { try { localStorage.setItem('sqlrunboard-proto:' + k, JSON.stringify(v)); } catch {} }, -}; -const app = { theme: store.get('theme', 'System'), view: store.get('view', 'board'), tlColumns: store.get('tlColumns', false), tlCollapse: store.get('tlCollapse', false), drawerWidth: store.get('drawerWidth', 360), batches: [], cur: null, dialog: null, menu: null }; +// ---------- app settings: settings.json in the app data folder. The snapshot gives them; each change goes to the server. ---------- +const PREFS = { theme: 'System', view: 'board', tlColumns: false, tlCollapse: false, drawerWidth: 360 }; +const store = { set(k, v) { sendEdit('/api/app-settings', { [k]: v }); } }; +const app = { ...PREFS, batches: [], cur: null, dialog: null, menu: null }; // ---------- server state ---------- // The server keeps all batch state. Each server batch merges into a page object that also keeps the view state (tab, selection, drawer). // The page uses the flat settings of the prototype; the server uses the batch file shape (target is nested). @@ -241,7 +239,9 @@ function connect() { const es = new EventSource('/events'); es.addEventListener('snapshot', e => { - const ids = JSON.parse(e.data).batches.map(sb => upsert(sb).id); + const snap = JSON.parse(e.data), ids = snap.batches.map(sb => upsert(sb).id); + for (const k in PREFS) if (k in snap.app) app[k] = snap.app[k]; + applyTheme(); setDrawerWidth(app.drawerWidth); app.batches = app.batches.filter(b => ids.includes(b.id)); if (!app.batches.some(b => b.id === app.cur)) app.cur = app.batches[0]?.id ?? null; document.documentElement.dataset.connected = ''; // tests wait for this diff --git a/tests/SqlRunboard.Gui.Tests/RunViewTests.cs b/tests/SqlRunboard.Gui.Tests/RunViewTests.cs new file mode 100644 index 0000000..9c96a67 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/RunViewTests.cs @@ -0,0 +1,100 @@ +using System.Text.RegularExpressions; +using System.Text.Json.Nodes; +using Microsoft.Playwright; +using SqlRunboard.Core; +using SqlRunboard.Gui; + +public class RunViewTests : AppPageTest +{ + readonly string scripts = Directory.CreateTempSubdirectory("sqlrunboard-scripts-").FullName; + + ILocator Panel => Page.Locator("#panel"); + ILocator View(string name) => Panel.GetByRole(AriaRole.Group, new() { Name = "View" }).GetByRole(AriaRole.Button, new() { Name = name }); + ILocator Side(string name) => Page.GetByRole(AriaRole.Navigation, new() { Name = "Batches" }).Locator($"[role=option]:has-text('{name}')"); + ILocator Rows => Panel.Locator("table tbody tr"); + + Batch RunningBatch() + { + foreach (var f in new[] { "a.sql", "b.sql", "c.sql" }) File.WriteAllText(Path.Combine(scripts, f), "select 1"); + App.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(10), Rows = 5 }; + App.Executor.Scripts["b.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(10) }; + var b = App.Host.Batches.NewBatch(); + App.Host.Batches.UpdateSettings(b.Id, new BatchSettings { Folder = scripts, ParallelLimit = 1 }); + return App.Host.Batches.Run(b.Id)!; + } + + [Fact] + public async Task The_table_shows_the_run_in_queue_order() + { + var b = RunningBatch(); + await Side(b.Name).ClickAsync(); + await View("Table").ClickAsync(); + + await Expect(Panel.Locator("table thead th")).ToHaveTextAsync(["#", "Status", "Script", "Start", "Elapsed", "Attempts", "Rows"]); + await Expect(Rows.Locator("td:nth-child(3)")).ToHaveTextAsync(["a.sql", "b.sql", "c.sql"]); + await Expect(Rows.Locator("td:nth-child(1)")).ToHaveTextAsync(["", "1", "2"]); // the queue position only for Queued scripts + + App.Time.Advance(TimeSpan.FromSeconds(10)); + await Expect(Rows.Nth(0)).ToContainTextAsync("Succeeded"); + await Expect(Rows.Nth(0).Locator("td")).ToHaveTextAsync(new Regex[] { new("^$"), new("Succeeded"), new("^a.sql$"), new(@"^\d\d:\d\d:\d\d$"), new("^0:10$"), new("^1$"), new("^5$") }); + } + + [Fact] + public async Task Board_and_table_use_the_same_selection() + { + var b = RunningBatch(); + await Side(b.Name).ClickAsync(); + await Panel.Locator(".card:has-text('b.sql')").ClickAsync(); + await View("Table").ClickAsync(); + + await Expect(Panel.Locator("tr.issel td:nth-child(3)")).ToHaveTextAsync("b.sql"); + await Panel.Locator("tr:has-text('c.sql')").ClickAsync(); + await View("Board").ClickAsync(); + await Expect(Panel.Locator(".card.issel")).ToContainTextAsync("c.sql"); + } + + [Fact] + public async Task The_right_click_menu_works_on_a_table_row() + { + var b = RunningBatch(); + await Side(b.Name).ClickAsync(); + await View("Table").ClickAsync(); + await Panel.Locator("tr:has-text('c.sql')").ClickAsync(new() { Button = MouseButton.Right }); + + var menu = Page.GetByRole(AriaRole.Menu, new() { Name = "c.sql" }); + await Expect(menu).ToBeVisibleAsync(); + await Expect(menu.GetByRole(AriaRole.Menuitem, new() { Name = "Move to top" })).ToBeEnabledAsync(); // c.sql is Queued + await Expect(Panel.Locator("tr.issel td:nth-child(3)")).ToHaveTextAsync("c.sql"); + } + + [Fact] + public async Task The_Run_tab_opens_the_last_view_also_after_a_restart() + { + var b = RunningBatch(); + await Side(b.Name).ClickAsync(); + await View("Table").ClickAsync(); + await Expect(View("Table")).ToHaveAttributeAsync("aria-pressed", "true"); + + var file = Path.Combine(App.AppData, AppSettings.FileName); + for (var i = 0; i < 50 && !(File.Exists(file) && JsonNode.Parse(File.ReadAllText(file))?["view"]?.GetValue() == "table"); i++) await Task.Delay(100, TestContext.Current.CancellationToken); + Assert.Equal("table", JsonNode.Parse(File.ReadAllText(file))?["view"]?.GetValue()); + + await using var again = await GuiHost.StartAsync(new GuiHostOptions(App.AppData, App.Executor, App.Time)); + var b2 = again.Batches.NewBatch(); + again.Batches.UpdateSettings(b2.Id, new BatchSettings { Folder = scripts }); + again.Batches.Run(b2.Id); + await using var context = await Browser.NewContextAsync(); // a new browser profile: no local storage of the first page + var page = await context.NewPageAsync(); + await OpenAsync(page, again.StartUrl); + await page.Locator($"[role=option]:has-text('{b2.Name}')").ClickAsync(); + + await Expect(page.Locator("#panel table")).ToBeVisibleAsync(); + await Expect(page.GetByRole(AriaRole.Button, new() { Name = "Table" })).ToHaveAttributeAsync("aria-pressed", "true"); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + Directory.Delete(scripts, recursive: true); + } +} From 63f9af129ccab438af034d7aba1137cb26e7c7e6 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:59:29 +0200 Subject: [PATCH 33/96] fix(core): cancel the run of a batch that you remove from the list --- src/SqlRunboard.Core/BatchList.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index b86a34c..461621d 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -115,15 +115,15 @@ Batch Load(Batch b) return b with { Dirty = false, Settings = settings, Scripts = Merge(scripts, FileScan.Scan(FolderPath(b.File, settings.Folder), settings.Filters, settings.Recurse)) }; } - /// "Remove from list". The file stays on disk. False if no batch has this id. + /// "Remove from list". The file stays on disk. The run of the batch is cancelled. False if no batch has this id. public bool Remove(int id) { + CancelAll(id); // outside the lock: see Cancel lock (gate) { if (batches.RemoveAll(b => b.Id == id) == 0) return false; loaded.Remove(id); - // ponytail: Running scripts of a removed batch finish on their own; only the Queued ones stop. Cancel them when #8 adds cancel. - if (runs.Remove(id, out var run)) run.Queue.Clear(); + runs.Remove(id); Publish(new Change("remove", new { id })); return true; } From 8f1e2d4cc9e913ce18fc2ee4a43684e0dffcecb9 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:02:48 +0200 Subject: [PATCH 34/96] feat(core): add reruns and end the run when the target or file scan changes --- src/SqlRunboard.Core/BatchList.cs | 46 +++++++++++- src/SqlRunboard.Core/Run.cs | 3 +- tests/SqlRunboard.Core.Tests/RunTests.cs | 94 ++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 4 deletions(-) diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index 1ed775d..83c8418 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -35,10 +35,20 @@ public Batch NewBatch() lock (gate) { // ponytail: the scan runs under the lock; move it out if a slow network folder blocks the page. - return Replace(id, b => b with { Settings = settings, Scripts = Merge(b.Scripts, FileScan.Scan(FolderPath(b.File, settings.Folder), settings.Filters, settings.Recurse)) }); + return Replace(id, b => + { + var run = b.Run; + if (run is { Ended: false } && EndsRun(b.Settings, settings) && runs.Remove(id)) run = run with { Ended = true }; + return b with { Settings = settings, Scripts = Merge(b.Scripts, FileScan.Scan(FolderPath(b.File, settings.Folder), settings.Filters, settings.Recurse)), Run = run }; + }); } } + /// A change to the target or the file scan ends the current run. The other settings apply to the next reruns. + static bool EndsRun(BatchSettings a, BatchSettings b) => + a.Target with { PasswordVariable = null } != b.Target with { PasswordVariable = null } + || (a.Folder, a.Filters, a.Recurse) != (b.Folder, b.Filters, b.Recurse); + /// Sets or clears the check box of one file. Null if no batch has this id. public Batch? SetInclude(int id, string path, bool include) { @@ -113,12 +123,42 @@ public Batch NewBatch() return batch; } + /// + /// Reruns one completed script of the current run. It goes to the bottom of the queue and reads its file again. + /// Returns the batch, or null if no batch has this id. + /// + public Batch? Rerun(int id, string path) => Rerun(id, _ => [path]); + + /// Reruns all Failed and Cancelled scripts of the current run, in the start order. Null if no batch has this id. + public Batch? RerunFailed(int id) => + Rerun(id, run => [.. run.Scripts.Values.Where(s => s.Status is ScriptStatus.Failed or ScriptStatus.Cancelled).Select(s => s.Path)]); + + // Reruns ignore the check boxes. They add attempts to the current run. + Batch? Rerun(int id, Func> paths) + { + lock (gate) + { + if (runs.GetValueOrDefault(id) is { } run && Find(id) is { } batch) + { + run.Settings = batch.Settings; // reruns use the current parallel limit, stop setting and timeout + foreach (var path in paths(run)) + if (run.Scripts.TryGetValue(path, out var s) && s.Status is ScriptStatus.Succeeded or ScriptStatus.Failed or ScriptStatus.Cancelled) + { + run.Scripts[path] = s with { Status = ScriptStatus.Queued, Error = null }; + run.Queue.Add(path); + } + Pump(id, run); + } + return Find(id); + } + } + /// The mutable state of a run. Change it only under the lock. sealed class ActiveRun(DateTimeOffset start, string folder, BatchSettings settings) { public DateTimeOffset Start => start; public string Folder => folder; - public BatchSettings Settings => settings; + public BatchSettings Settings { get; set; } = settings; // a rerun sets the current settings; they cannot change while scripts run public OrderedDictionary Scripts { get; } = new(StringComparer.Ordinal); // the start order public List Queue { get; } = []; public int Running { get; set; } @@ -129,7 +169,7 @@ sealed class ActiveRun(DateTimeOffset start, string folder, BatchSettings settin /// "Stop the queue at the first failure": no new scripts start. The Running scripts continue. public void Failed() { - if (!settings.StopOnFirstFailure) return; + if (!Settings.StopOnFirstFailure) return; foreach (var path in Queue) Scripts[path] = Scripts[path] with { Status = ScriptStatus.Cancelled, Error = "Not started: the queue stopped" }; Queue.Clear(); } diff --git a/src/SqlRunboard.Core/Run.cs b/src/SqlRunboard.Core/Run.cs index 1902c50..d4b3a3e 100644 --- a/src/SqlRunboard.Core/Run.cs +++ b/src/SqlRunboard.Core/Run.cs @@ -9,8 +9,9 @@ public sealed record ScriptRun(string Path, ScriptStatus Status, IReadOnlyList /// The current run of a batch, as the page sees it. Scripts are in the start order; Queue has the Queued paths in queue order. /// Now is the server clock when this state was made, so the page can show the elapsed time of Running scripts. +/// Ended: the target or the file scan changed after the run. Reruns do nothing until the next Run. ///
-public sealed record RunState(DateTimeOffset Start, DateTimeOffset Now, IReadOnlyList Scripts, IReadOnlyList Queue) +public sealed record RunState(DateTimeOffset Start, DateTimeOffset Now, IReadOnlyList Scripts, IReadOnlyList Queue, bool Ended = false) { public bool Active => Scripts.Any(s => s.Status is ScriptStatus.Queued or ScriptStatus.Running); } diff --git a/tests/SqlRunboard.Core.Tests/RunTests.cs b/tests/SqlRunboard.Core.Tests/RunTests.cs index 1ef7937..8ba3c5c 100644 --- a/tests/SqlRunboard.Core.Tests/RunTests.cs +++ b/tests/SqlRunboard.Core.Tests/RunTests.cs @@ -237,6 +237,100 @@ public async Task Stop_the_queue_at_the_first_failure_cancels_the_Queued_scripts Assert.DoesNotContain(executor.Calls, c => c.ScriptName == "d.sql"); } + [Fact] + public async Task A_rerun_reads_the_file_again_goes_to_the_bottom_of_the_queue_and_adds_an_attempt() + { + var b = NewBatch(1, "a.sql", "b.sql", "c.sql"); + Script("a.sql", 5, ScriptStatus.Failed); Script("b.sql", 5); Script("c.sql", 5); + list.Run(b.Id); + time.Advance(TimeSpan.FromSeconds(5)); + await Until(b.Id, r => StatusOf(r, "b.sql") == ScriptStatus.Running); + + Write("a.sql", "select 'fixed'"); + Script("a.sql", 5); + var run = list.Rerun(b.Id, "a.sql")!.Run!; + Assert.Equal(["c.sql", "a.sql"], run.Queue); + Assert.Equal(ScriptStatus.Queued, StatusOf(run, "a.sql")); + Assert.Equal(["c.sql", "a.sql"], list.Rerun(b.Id, "b.sql")!.Run!.Queue); // a Running script does not rerun + + time.Advance(TimeSpan.FromSeconds(5)); + await Until(b.Id, r => StatusOf(r, "c.sql") == ScriptStatus.Running); + time.Advance(TimeSpan.FromSeconds(5)); + await Until(b.Id, r => StatusOf(r, "a.sql") == ScriptStatus.Running); + time.Advance(TimeSpan.FromSeconds(5)); + run = await Until(b.Id, r => !r.Active); + + Assert.Equal([ScriptStatus.Failed, ScriptStatus.Succeeded], run.Scripts[0].Attempts.Select(a => a.Status)); + Assert.Equal(["a.sql", "b.sql", "c.sql", "a.sql"], executor.Calls.Select(c => c.ScriptName)); + Assert.Equal([new ScriptPart("select 'fixed'", 1, 1)], executor.Calls.Last().Parts); + } + + [Fact] + public async Task Rerun_all_failed_opens_the_run_again_in_the_start_order_with_the_current_settings_and_ignores_the_check_boxes() + { + var b = NewBatch(4, "a.sql", "b.sql", "c.sql", "d.sql"); + Script("a.sql", 5); Script("b.sql", 10, ScriptStatus.Failed); Script("c.sql", 5, ScriptStatus.Failed); Script("d.sql", 60); + list.Run(b.Id); + list.Cancel(b.Id, "d.sql"); + time.Advance(TimeSpan.FromSeconds(10)); + await Until(b.Id, r => !r.Active); + list.SetInclude(b.Id, "b.sql", false); + list.UpdateSettings(b.Id, b.Settings with { ParallelLimit = 1, StopOnFirstFailure = true }); + + var run = list.RerunFailed(b.Id)!.Run!; + Assert.True(run.Active); + Assert.Equal(ScriptStatus.Running, StatusOf(run, "b.sql")); + Assert.Equal(["c.sql", "d.sql"], run.Queue); + + time.Advance(TimeSpan.FromSeconds(10)); // b fails again: the stop setting applies to reruns + run = await Until(b.Id, r => !r.Active && r.Scripts[1].Attempts.Count == 2); + Assert.Equal([1, 2, 1, 1], run.Scripts.Select(s => s.Attempts.Count)); + Assert.Equal("Not started: the queue stopped", run.Scripts[2].Error); + } + + [Theory] + [InlineData("server")] + [InlineData("database")] + [InlineData("auth")] + [InlineData("user")] + [InlineData("encrypt")] + [InlineData("extra")] + [InlineData("folder")] + [InlineData("filters")] + [InlineData("recurse")] + public async Task A_change_to_the_target_or_the_file_scan_ends_the_run_and_reruns_do_nothing(string change) + { + var b = NewBatch(4, "a.sql"); + Script("a.sql", 5, ScriptStatus.Failed); + list.Run(b.Id); + time.Advance(TimeSpan.FromSeconds(5)); + await Until(b.Id, r => !r.Active); + var s = b.Settings with { ParallelLimit = 2, StopOnFirstFailure = true, TimeoutSeconds = 9, ConfirmBeforeRun = true }; + Assert.False(list.UpdateSettings(b.Id, s)!.Run!.Ended); + + var t = s.Target; + var run = list.UpdateSettings(b.Id, change switch + { + "server" => s with { Target = t with { Server = "other" } }, + "database" => s with { Target = t with { Database = "other" } }, + "auth" => s with { Target = t with { Auth = Auth.SqlServer } }, + "user" => s with { Target = t with { User = "other" } }, + "encrypt" => s with { Target = t with { Encrypt = Encrypt.Strict } }, + "extra" => s with { Target = t with { ExtraConnectionString = "App=x" } }, + "folder" => s with { Folder = root + Path.DirectorySeparatorChar }, + "filters" => s with { Filters = "*.txt" }, + _ => s with { Recurse = true }, + })!.Run!; + + Assert.True(run.Ended); + Assert.Equal(run, list.RerunFailed(b.Id)!.Run); + Assert.Equal(run, list.Rerun(b.Id, "a.sql")!.Run); + Assert.Single(executor.Calls); + var next = list.Run(b.Id)!.Run!; // Run starts a new run, with attempt counts from 0 + Assert.False(next.Ended); + Assert.All(next.Scripts, x => Assert.Single(x.Attempts)); + } + public void Dispose() { sub.Dispose(); From b2a299435999060e2c8f327bf4056c163b553710 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:05:18 +0200 Subject: [PATCH 35/96] feat(gui): add Rerun, Rerun all failed and the settings-changed note on the Run tab --- src/SqlRunboard.Gui/GuiHost.cs | 4 +++ src/SqlRunboard.Gui/wwwroot/index.html | 20 ++++++----- tests/SqlRunboard.Gui.Tests/BoardTests.cs | 43 +++++++++++++++++++++++ 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 205242a..c71100f 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -76,6 +76,10 @@ public static async Task StartAsync(GuiHostOptions options) list.Cancel(id, r.Path) is { } b ? Results.Ok(b) : Results.NotFound()); app.MapPost("/api/batches/{id:int}/cancel-all", (int id, BatchList list) => list.CancelAll(id) is { } b ? Results.Ok(b) : Results.NotFound()); + app.MapPost("/api/batches/{id:int}/rerun", (int id, ScriptRequest r, BatchList list) => + list.Rerun(id, r.Path) is { } b ? Results.Ok(b) : Results.NotFound()); + app.MapPost("/api/batches/{id:int}/rerun-failed", (int id, BatchList list) => + list.RerunFailed(id) is { } b ? Results.Ok(b) : Results.NotFound()); // The browser has no folder picker that gives a path, so the page browses the folders here. app.MapPost("/api/folders", (FolderRequest r) => { diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index e96755c..bc209c6 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -219,7 +219,7 @@ const t0 = Date.parse(r.start), sec = x => x == null ? null : (Date.parse(x) - t0) / 1000, skew = Date.parse(r.now) - Date.now(), rs = {}; for (const s of r.scripts) rs[s.path] = { status: s.status, error: s.error, msgs: [], attempts: s.attempts.map((a, i) => ({ n: i + 1, start: sec(a.start), end: sec(a.end), status: a.status, rows: a.rows, error: a.error })) }; const ended = !r.scripts.some(s => s.status === 'Queued' || s.status === 'Running'); - return { rs, queue: r.queue, wall: new Date(t0), live: true, ended, endAt: ended ? Math.max(0, ...Object.values(rs).flatMap(x => x.attempts.map(a => a.end ?? 0))) : null, get t() { return (Date.now() + skew - t0) / 1000; } }; + return { rs, queue: r.queue, wall: new Date(t0), live: true, ended, stale: r.ended, endAt: ended ? Math.max(0, ...Object.values(rs).flatMap(x => x.attempts.map(a => a.end ?? 0))) : null, get t() { return (Date.now() + skew - t0) / 1000; } }; } const fromView = s => ({ folder: s.folder || null, filters: s.filters, recurse: s.recurse, parallelLimit: +s.parallelLimit, stopOnFirstFailure: s.stopOnFirstFailure, confirmBeforeRun: s.confirmBeforeRun, timeoutSeconds: s.timeoutSeconds === '' ? null : +s.timeoutSeconds, @@ -263,10 +263,11 @@ } const canMove = (b, path) => b.run ? !b.run.ended && b.run.rs[path]?.status === 'Queued' : !locked(b) && !!b.scripts.find(x => x.path === path); const locked = b => !!b.signing || !!(b.run && b.run.live && !b.run.ended); +const canRerun = (b, r) => DONE.includes(r.status) && !b.signing && !b.run.stale; // stale: the settings changed, so the run ended // ponytail: stand-ins for the commands of later tickets. Each ticket replaces its names with a POST. const todo = () => toast('Not available yet.'); -const rerun = todo, move = todo, logon = todo, saveBatch = todo, openBatch = todo, removeBatch = todo; +const move = todo, logon = todo, saveBatch = todo, openBatch = todo, removeBatch = todo; // Edits go to the server one at a time, in the order you make them. let edits = Promise.resolve(); const sendEdit = (url, body) => edits = edits.then(() => post(url, body)).catch(e => { toast(e.message); return null; }); @@ -281,6 +282,8 @@ } const cancel = (b, path) => sendEdit(`/api/batches/${b.id}/cancel`, { path }); const cancelAll = b => sendEdit(`/api/batches/${b.id}/cancel-all`, {}); +const rerun = (b, path) => sendEdit(`/api/batches/${b.id}/rerun`, { path }); +const rerunFailed = b => sendEdit(`/api/batches/${b.id}/rerun-failed`, {}); function setInclude(b, s, include) { s.include = include; render(); sendEdit(`/api/batches/${b.id}/include`, { path: s.path, include }); } async function browse(b, path) { // the folder dialog: the server lists the folders try { openDialog({ kind: 'folder', b, ...await post('/api/folders', { path }) }); } catch { if (path) browse(b, null); else toast('The folder list is not available.'); } @@ -378,7 +381,7 @@ ${b.signing ? `${esc(b.signing.text)}` : ``} - + ${run ? `${c.Succeeded + bad} of ${n} done${bad ? `, ${bad} failed` : ''}${run.ended ? ' · Run ended' : ''}` : 'Not run yet'} ${b.logonMsg ? `` : ''}`; @@ -393,6 +396,7 @@ if (app.view === 'timeline') head += ``; head += ''; if (run?.restored && !run.live) head += `

Last run, from logs/${esc(b.name)}_${stamp(run.wall)}/summary.csv.${run.unfinished ? ` Interrupted: the app stopped at or after ${clock(run, run.newest)}.` : ''} Rerun opens this run again. Run starts a new run.

`; + if (run?.stale) head += '

The settings changed. Run the batch again.

'; if (!run) head += '

No run yet. These scripts are Queued in the start order. Move them here or in the Files tab.

'; return head + (app.view === 'board' ? renderBoard(b) : app.view === 'table' ? renderTable(b) : renderTimeline(b)); } @@ -521,7 +525,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} } return `
${bubble(r.status)}${esc(x.path)}
- + ${['top', 'up', 'down', 'bottom'].map(d => ``).join('')}
@@ -618,7 +622,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} el.innerHTML = ``; el.querySelector('button:not(:disabled)')?.focus(); render(); @@ -649,9 +653,9 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} case 'run': return runBatch(b); case 'cancelSignIn': b.signing = null; return render(); case 'cancelAll': cancelAll(b); return render(); - case 'rerunFailed': rerun(b, Object.entries(b.run.rs).filter(([, r]) => r.status === 'Failed' || r.status === 'Cancelled').map(([p]) => p)); return render(); + case 'rerunFailed': rerunFailed(b); return render(); case 'dCancel': cancel(b, b.sel); return render(); - case 'dRerun': rerun(b, [b.sel]); return render(); + case 'dRerun': rerun(b, b.sel); return render(); case 'closeDrawer': b.drawer = false; render(); return document.querySelector(`[data-k="s:${CSS.escape(b.sel)}"]`)?.focus(); case 'runlog': return todo(); case 'test': return logon(b, login => { b.testMsg = `Connected. Server ${b.settings.server.split('.')[0].toUpperCase()}, database ${b.settings.database}, login ${login}.`; }); @@ -669,7 +673,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} const mc = t.closest('[data-mcmd]'); if (mc) { const { b: mb, path } = app.menu, c = mc.dataset.mcmd; closeMenu(); - if (c === 'open') selectScript(mb, path, true); else if (c === 'cancel') cancel(mb, path); else if (c === 'rerun') rerun(mb, [path]); else move(mb, path, c); + if (c === 'open') selectScript(mb, path, true); else if (c === 'cancel') cancel(mb, path); else if (c === 'rerun') rerun(mb, path); else move(mb, path, c); return render(); } const c = t.closest('[data-cmd]'); if (c) { e.preventDefault(); return cmd(c.dataset.cmd, c); } diff --git a/tests/SqlRunboard.Gui.Tests/BoardTests.cs b/tests/SqlRunboard.Gui.Tests/BoardTests.cs index 89a58c0..8996b80 100644 --- a/tests/SqlRunboard.Gui.Tests/BoardTests.cs +++ b/tests/SqlRunboard.Gui.Tests/BoardTests.cs @@ -123,6 +123,49 @@ public async Task The_drawer_of_a_script_that_did_not_start_says_why() await Expect(Page.Locator("#drawer .msgs")).ToContainTextAsync("Not started: the queue stopped"); } + [Fact] + public async Task Rerun_from_the_card_menu_and_Rerun_all_failed_open_the_run_again() + { + var b = NewBatch(4, "a.sql", "b.sql", "c.sql"); + Script("a.sql", 5, ScriptStatus.Failed); Script("b.sql", 5); Script("c.sql", 5, ScriptStatus.Failed); + App.Host.Batches.Run(b.Id); + await Side(b.Name).ClickAsync(); + App.Time.Advance(TimeSpan.FromSeconds(5)); + await Expect(Cards("Failed / Cancelled")).ToHaveCountAsync(2); + await Expect(Side(b.Name).Locator(".st")).ToHaveTextAsync("✕"); + + Script("b.sql", 60); + await Column("Succeeded").Locator(".card:has-text('b.sql')").ClickAsync(new() { Button = MouseButton.Right }); + await Page.GetByRole(AriaRole.Menuitem, new() { Name = "Rerun" }).ClickAsync(); + await Expect(Cards("Running")).ToHaveTextAsync(["b.sql"]); + await Expect(Column("Running").Locator(".card .r2")).ToContainTextAsync("attempt 2"); + await Expect(Side(b.Name).Locator(".st")).ToHaveTextAsync("▶"); + await Expect(Header.GetByRole(AriaRole.Button, new() { Name = "Run", Exact = true })).ToBeDisabledAsync(); + + await Header.GetByRole(AriaRole.Button, new() { Name = "Rerun all failed" }).ClickAsync(); + await Expect(Cards("Running")).ToHaveCountAsync(3); + await Expect(Cards("Failed / Cancelled")).ToHaveCountAsync(0); + } + + [Fact] + public async Task A_target_change_ends_the_run_and_disables_the_reruns() + { + var b = NewBatch(4, "a.sql"); + Script("a.sql", 5, ScriptStatus.Failed); + App.Host.Batches.Run(b.Id); + await Side(b.Name).ClickAsync(); + App.Time.Advance(TimeSpan.FromSeconds(5)); + var rerunFailed = Header.GetByRole(AriaRole.Button, new() { Name = "Rerun all failed" }); + await Expect(rerunFailed).ToBeEnabledAsync(); + + App.Host.Batches.UpdateSettings(b.Id, b.Settings with { Target = b.Settings.Target with { Server = "other" } }); + + await Expect(Panel).ToContainTextAsync("The settings changed. Run the batch again."); + await Expect(rerunFailed).ToBeDisabledAsync(); + await Column("Failed / Cancelled").Locator(".card:has-text('a.sql')").ClickAsync(new() { Button = MouseButton.Right }); + await Expect(Page.GetByRole(AriaRole.Menuitem, new() { Name = "Rerun" })).ToBeDisabledAsync(); + } + public override async ValueTask DisposeAsync() { await base.DisposeAsync(); From e5d170d230b950443f600f96c169daae344dd908 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:05:27 +0200 Subject: [PATCH 36/96] feat(core): add the SqlClient executor with per-script connections and file-line errors --- src/SqlRunboard.Core/FakeSqlExecutor.cs | 12 +- src/SqlRunboard.Core/SqlExecutor.cs | 12 +- src/SqlRunboard.Core/SqlRunboard.Core.csproj | 3 + src/SqlRunboard.Core/SqlServerExecutor.cs | 103 ++++++++++++++++++ .../ConnectionStringTests.cs | 69 ++++++++++++ 5 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 src/SqlRunboard.Core/SqlServerExecutor.cs create mode 100644 tests/SqlRunboard.Core.Tests/ConnectionStringTests.cs diff --git a/src/SqlRunboard.Core/FakeSqlExecutor.cs b/src/SqlRunboard.Core/FakeSqlExecutor.cs index 7e6d272..2bdbd17 100644 --- a/src/SqlRunboard.Core/FakeSqlExecutor.cs +++ b/src/SqlRunboard.Core/FakeSqlExecutor.cs @@ -10,6 +10,7 @@ public sealed record FakeScript public long? Rows { get; init; } public string? Error { get; init; } public IReadOnlyList Messages { get; init; } = []; + public bool LogonFailed { get; init; } } /// @@ -20,6 +21,9 @@ public sealed class FakeSqlExecutor(TimeProvider time) : ISqlExecutor { public ConcurrentDictionary Scripts { get; } = new(); public ConcurrentQueue Calls { get; } = new(); + public ConcurrentQueue Logons { get; } = new(); + /// What Test connection gives. The default connects as the user (or "fake") to the server and database of the target. + public Func Logon { get; set; } = c => new SqlLogon(c.Target.Server, c.Target.Database, c.Target.User ?? "fake"); public async Task ExecuteAsync(SqlExecution execution, Action onMessage, CancellationToken ct) { @@ -28,6 +32,12 @@ public async Task ExecuteAsync(SqlExecution execution, Action LogonAsync(SqlConnectionSettings connection, CancellationToken ct) + { + Logons.Enqueue(connection); + return Task.FromResult(Logon(connection)); } } diff --git a/src/SqlRunboard.Core/SqlExecutor.cs b/src/SqlRunboard.Core/SqlExecutor.cs index 893feb4..97242ef 100644 --- a/src/SqlRunboard.Core/SqlExecutor.cs +++ b/src/SqlRunboard.Core/SqlExecutor.cs @@ -15,11 +15,17 @@ public sealed record SqlConnectionSettings(BatchTarget Target, string? Password) /// public sealed record SqlExecution(string ScriptName, IReadOnlyList Parts, SqlConnectionSettings Connection); -/// Status is Succeeded or Failed. A cancel throws OperationCanceledException. -public sealed record SqlResult(ScriptStatus Status, long? Rows, string? Error); +/// Status is Succeeded or Failed. A cancel throws OperationCanceledException. LogonFailed: the server rejected the logon (wrong password). +public sealed record SqlResult(ScriptStatus Status, long? Rows, string? Error, bool LogonFailed = false); -/// The seam to SQL Server. The real adapter comes with ticket #7; tests use . +/// Test connection: @@SERVERNAME, the database and the login, or the Error. LogonFailed: ask for the password again. +public sealed record SqlLogon(string? Server, string? Database, string? Login, string? Error = null, bool LogonFailed = false); + +/// The seam to SQL Server: ; tests use . public interface ISqlExecutor { Task ExecuteAsync(SqlExecution execution, Action onMessage, CancellationToken ct); + + /// Opens a connection to the target and reads who and where it is. It does not throw for a logon or network error. + Task LogonAsync(SqlConnectionSettings connection, CancellationToken ct); } diff --git a/src/SqlRunboard.Core/SqlRunboard.Core.csproj b/src/SqlRunboard.Core/SqlRunboard.Core.csproj index 35e3d84..7ad41f4 100644 --- a/src/SqlRunboard.Core/SqlRunboard.Core.csproj +++ b/src/SqlRunboard.Core/SqlRunboard.Core.csproj @@ -1,2 +1,5 @@ + + + diff --git a/src/SqlRunboard.Core/SqlServerExecutor.cs b/src/SqlRunboard.Core/SqlServerExecutor.cs new file mode 100644 index 0000000..4e9b684 --- /dev/null +++ b/src/SqlRunboard.Core/SqlServerExecutor.cs @@ -0,0 +1,103 @@ +using Microsoft.Data.SqlClient; + +namespace SqlRunboard.Core; + +/// The real : Microsoft.Data.SqlClient. See docs/SPEC.md "Execution". +public sealed class SqlServerExecutor : ISqlExecutor +{ + /// + /// The additional text first, then the target settings over it. Pooling is always off, and the application name is + /// "SQLRunboard" plus the script name (null for Test connection). + /// + public static string ConnectionString(SqlConnectionSettings connection, string? scriptName) + { + var t = connection.Target; + var b = new SqlConnectionStringBuilder(t.ExtraConnectionString) + { + Encrypt = t.Encrypt switch { Encrypt.Optional => SqlConnectionEncryptOption.Optional, Encrypt.Strict => SqlConnectionEncryptOption.Strict, _ => SqlConnectionEncryptOption.Mandatory }, + TrustServerCertificate = t.TrustServerCertificate, + Pooling = false, + ApplicationName = scriptName is null ? "SQLRunboard" : ("SQLRunboard " + scriptName)[..Math.Min(128, 12 + scriptName.Length)], + }; + if (t.Server is not null) b.DataSource = t.Server; + if (t.Database is not null) b.InitialCatalog = t.Database; + switch (t.Auth) + { + case Auth.Integrated: b.IntegratedSecurity = true; break; + case Auth.SqlServer: b.UserID = t.User ?? ""; b.Password = connection.Password ?? ""; break; + default: throw new NotSupportedException("Microsoft Entra ID logon comes in a later version. Use Integrated or SQL Server."); + } + return b.ConnectionString; + } + + /// + /// One new connection for the script. The parts run in order, each Repeat times, with no transaction from the app. + /// Result sets are discarded; each statement's row count comes as a message. The first failed part stops the script. + /// The engine owns the timeout: a cancel of the token always ends as a cancelled task, never as a SQL error. + /// + public async Task ExecuteAsync(SqlExecution execution, Action onMessage, CancellationToken ct) + { + try + { + await using var connection = new SqlConnection(ConnectionString(execution.Connection, execution.ScriptName)); + connection.InfoMessage += (_, e) => { foreach (SqlError info in e.Errors) onMessage(new(SqlMessageKind.Info, info.Message)); }; + try { await connection.OpenAsync(ct); } + catch (SqlException e) when (!ct.IsCancellationRequested) + { + onMessage(new(SqlMessageKind.Error, e.Message)); + return new(ScriptStatus.Failed, null, e.Message, IsLogonFailure(e)); + } + long? rows = null; + for (var i = 0; i < execution.Parts.Count; i++) + { + var part = execution.Parts[i]; + for (var n = 0; n < part.Repeat; n++) + { + await using var command = new SqlCommand(part.Text, connection) { CommandTimeout = 0 }; + command.StatementCompleted += (_, e) => + { + rows = (rows ?? 0) + e.RecordCount; + onMessage(new(SqlMessageKind.RowCount, e.RecordCount == 1 ? "1 row affected" : $"{e.RecordCount} rows affected")); + }; + try { await command.ExecuteNonQueryAsync(ct); } + catch (SqlException e) when (!ct.IsCancellationRequested) + { + string? first = null; + foreach (SqlError error in e.Errors) + { + if (error.Class <= 10) { onMessage(new(SqlMessageKind.Info, error.Message)); continue; } + // The server counts lines in the part; the file line adds the line where the part starts. + var text = $"line {part.Line + Math.Max(error.LineNumber, 1) - 1} (part {i + 1}): {error.Message}"; + first ??= text; + onMessage(new(SqlMessageKind.Error, text)); + } + return new(ScriptStatus.Failed, rows, first ?? e.Message); + } + } + } + return new(ScriptStatus.Succeeded, rows, null); + } + catch (Exception e) when (e is not OperationCanceledException && ct.IsCancellationRequested) + { + throw new OperationCanceledException(ct); // SqlClient reports a cancel as a SqlException + } + } + + public async Task LogonAsync(SqlConnectionSettings connection, CancellationToken ct) + { + try + { + await using var c = new SqlConnection(ConnectionString(connection, scriptName: null)); + await c.OpenAsync(ct); + await using var command = new SqlCommand("SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()", c); + await using var r = await command.ExecuteReaderAsync(ct); + await r.ReadAsync(ct); + return new(r.IsDBNull(0) ? null : r.GetString(0), r.GetString(1), r.GetString(2)); + } + catch (SqlException e) { return new(null, null, null, e.Message, IsLogonFailure(e)); } + catch (Exception e) when (e is not OperationCanceledException) { return new(null, null, null, e.Message); } + } + + /// 18456: "Login failed for user". Another password can help; a network or database error cannot. + static bool IsLogonFailure(SqlException e) => e.Number == 18456; +} diff --git a/tests/SqlRunboard.Core.Tests/ConnectionStringTests.cs b/tests/SqlRunboard.Core.Tests/ConnectionStringTests.cs new file mode 100644 index 0000000..b4acf5b --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/ConnectionStringTests.cs @@ -0,0 +1,69 @@ +using Microsoft.Data.SqlClient; +using SqlRunboard.Core; + +public class ConnectionStringTests +{ + static SqlConnectionStringBuilder Build(BatchTarget target, string? password = null, string? script = "001_tables.sql") => + new(SqlServerExecutor.ConnectionString(new SqlConnectionSettings(target, password), script)); + + [Fact] + public void Each_script_gets_its_own_unpooled_connection_named_after_it() + { + var b = Build(new BatchTarget { Server = "sql01", Database = "Sales" }); + Assert.Equal("sql01", b.DataSource); + Assert.Equal("Sales", b.InitialCatalog); + Assert.False(b.Pooling); + Assert.Equal("SQLRunboard 001_tables.sql", b.ApplicationName); + } + + [Fact] + public void Test_connection_uses_the_bare_application_name_and_a_long_name_is_cut_to_128() + { + Assert.Equal("SQLRunboard", Build(new BatchTarget { Server = "s" }, script: null).ApplicationName); + Assert.Equal(128, Build(new BatchTarget { Server = "s" }, script: new string('x', 200)).ApplicationName.Length); + } + + [Fact] + public void Integrated_logon_uses_integrated_security_and_no_password() + { + var b = Build(new BatchTarget { Server = "s", Auth = Auth.Integrated }, password: "ignored"); + Assert.True(b.IntegratedSecurity); + Assert.Equal("", b.Password); + } + + [Fact] + public void SQL_Server_logon_uses_the_user_and_the_password() + { + var b = Build(new BatchTarget { Server = "s", Auth = Auth.SqlServer, User = "deploy" }, password: "pw"); + Assert.False(b.IntegratedSecurity); + Assert.Equal("deploy", b.UserID); + Assert.Equal("pw", b.Password); + } + + [Theory] + [InlineData(Encrypt.Mandatory, "Mandatory")] + [InlineData(Encrypt.Optional, "Optional")] + [InlineData(Encrypt.Strict, "Strict")] + public void Encrypt_and_trust_server_certificate_go_into_the_connection_string(Encrypt encrypt, string expected) + { + var b = Build(new BatchTarget { Server = "s", Encrypt = encrypt, TrustServerCertificate = true }); + Assert.Equal(SqlConnectionEncryptOption.Parse(expected), b.Encrypt); + Assert.True(b.TrustServerCertificate); + } + + [Fact] + public void The_additional_text_adds_other_settings_and_cannot_turn_pooling_on() + { + var b = Build(new BatchTarget { Server = "s", ExtraConnectionString = "Connect Timeout=7;Pooling=true;MultiSubnetFailover=true" }); + Assert.Equal(7, b.ConnectTimeout); + Assert.True(b.MultiSubnetFailover); + Assert.False(b.Pooling); + } + + [Fact] + public void Entra_logon_is_not_supported_yet() + { + var e = Assert.Throws(() => Build(new BatchTarget { Server = "s", Auth = Auth.EntraDefault })); + Assert.Contains("Entra", e.Message); + } +} From 44e1273a60dc60c720c5ecdd6178bb8411d15539 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:05:27 +0200 Subject: [PATCH 37/96] feat(core): keep the batch password in memory and forget it on a logon failure or target change --- src/SqlRunboard.Core/BatchList.cs | 35 ++++++- tests/SqlRunboard.Core.Tests/LogonTests.cs | 108 +++++++++++++++++++++ 2 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 tests/SqlRunboard.Core.Tests/LogonTests.cs diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index 1ed775d..8a6befe 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -16,6 +16,7 @@ public sealed class BatchList(ISqlExecutor executor, TimeProvider time) readonly List batches = []; readonly Dictionary runs = []; readonly List> subscribers = []; + readonly Dictionary passwords = []; // in memory only: never in a Batch, a file or an event int lastId, lastUntitled; public Batch NewBatch() @@ -35,8 +36,37 @@ public Batch NewBatch() lock (gate) { // ponytail: the scan runs under the lock; move it out if a slow network folder blocks the page. - return Replace(id, b => b with { Settings = settings, Scripts = Merge(b.Scripts, FileScan.Scan(FolderPath(b.File, settings.Folder), settings.Filters, settings.Recurse)) }); + return Replace(id, b => + { + if (b.Settings.Target != settings.Target) passwords.Remove(id); // a new target asks for the password again + return b with { Settings = settings, Scripts = Merge(b.Scripts, FileScan.Scan(FolderPath(b.File, settings.Folder), settings.Filters, settings.Recurse)) }; + }); + } + } + + /// + /// Test connection, and the logon check before a run. A password replaces the kept one; null uses the kept one. + /// A SQL Server logon with no password gives LogonFailed with no Error and no connection: ask for it. + /// The server keeps a password that works and forgets one that the server rejects. Null if no batch has this id. + /// + public async Task LogonAsync(int id, string? password, CancellationToken ct) + { + BatchTarget target; + lock (gate) + { + if (Find(id) is not { } b) return null; + target = b.Settings.Target; + password ??= passwords.GetValueOrDefault(id); + } + if (target.Auth == Auth.SqlServer && password is null) return new SqlLogon(null, null, null, LogonFailed: true); + var logon = await executor.LogonAsync(new SqlConnectionSettings(target, password), ct); + lock (gate) + { + if (Find(id)?.Settings.Target != target) return logon; // the target changed meanwhile: keep nothing + if (logon.LogonFailed) passwords.Remove(id); + else if (logon.Error is null && password is not null) passwords[id] = password; } + return logon; } /// Sets or clears the check box of one file. Null if no batch has this id. @@ -156,7 +186,7 @@ void Pump(int id, ActiveRun run) } run.Running++; run.Add(path, new Attempt(start, null, ScriptStatus.Running, null, null)); - var execution = new SqlExecution(path, parts, new SqlConnectionSettings(run.Settings.Target, Password: null)); + var execution = new SqlExecution(path, parts, new SqlConnectionSettings(run.Settings.Target, passwords.GetValueOrDefault(id))); // The continuation runs on the thread pool, never inline here under the lock. var cts = run.Tokens[path] = run.Settings.TimeoutSeconds is int s ? new CancellationTokenSource(TimeSpan.FromSeconds(s), time) : new CancellationTokenSource(); _ = executor.ExecuteAsync(execution, _ => { }, cts.Token) @@ -180,6 +210,7 @@ void Finish(int id, ActiveRun run, string path, DateTimeOffset start, TaskThe password of a batch: the server keeps it in memory after a logon, and forgets it after a logon failure or a target change.
+public sealed class LogonTests : IDisposable +{ + readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-logon-").FullName; + readonly FakeTime time = new(); + readonly FakeSqlExecutor executor; + readonly BatchList list; + readonly CancellationToken ct = TestContext.Current.CancellationToken; + + public LogonTests() + { + executor = new FakeSqlExecutor(time); + list = new BatchList(executor, time); + } + + static readonly BatchTarget SqlLogon = new() { Server = "sql01", Database = "Sales", Auth = Auth.SqlServer, User = "deploy" }; + + int NewBatch(BatchTarget target) + { + File.WriteAllText(Path.Combine(root, "a.sql"), "select 1"); + var id = list.NewBatch().Id; + list.UpdateSettings(id, new BatchSettings { Folder = root, Target = target }); + return id; + } + + [Fact] + public async Task A_SQL_Server_logon_asks_for_the_password_first() + { + var id = NewBatch(SqlLogon); + + var logon = await list.LogonAsync(id, null, ct); + + Assert.Equal(new SqlLogon(null, null, null, null, LogonFailed: true), logon); + Assert.Empty(executor.Logons); + } + + [Fact] + public async Task The_server_keeps_the_password_for_the_next_logons_and_runs() + { + var id = NewBatch(SqlLogon); + + var first = await list.LogonAsync(id, "secret", ct); + var second = await list.LogonAsync(id, null, ct); + list.Run(id); + + Assert.Equal(new SqlLogon("sql01", "Sales", "deploy"), first); + Assert.Equal(first, second); + Assert.Equal(["secret", "secret"], executor.Logons.Select(c => c.Password)); + Assert.Equal("secret", Assert.Single(executor.Calls).Connection.Password); + } + + [Fact] + public async Task A_target_change_forgets_the_password_and_other_settings_do_not() + { + var id = NewBatch(SqlLogon); + await list.LogonAsync(id, "secret", ct); + + list.UpdateSettings(id, new BatchSettings { Folder = root, Target = SqlLogon, ParallelLimit = 2 }); + Assert.False((await list.LogonAsync(id, null, ct))!.LogonFailed); + + list.UpdateSettings(id, new BatchSettings { Folder = root, Target = SqlLogon with { Database = "Other" } }); + Assert.True((await list.LogonAsync(id, null, ct))!.LogonFailed); + } + + [Fact] + public async Task A_rejected_password_is_forgotten() + { + var id = NewBatch(SqlLogon); + executor.Logon = c => new SqlLogon(null, null, null, "Login failed for user 'deploy'.", LogonFailed: true); + + var logon = await list.LogonAsync(id, "wrong", ct); + executor.Logon = c => new SqlLogon("sql01", "Sales", "deploy"); + + Assert.Equal("Login failed for user 'deploy'.", logon!.Error); + Assert.Equal(new SqlLogon(null, null, null, null, LogonFailed: true), await list.LogonAsync(id, null, ct)); + } + + [Fact] + public async Task A_script_that_fails_its_logon_forgets_the_password() + { + var id = NewBatch(SqlLogon); + executor.Scripts["a.sql"] = new FakeScript { Status = ScriptStatus.Failed, Error = "Login failed for user 'deploy'.", LogonFailed = true }; + await list.LogonAsync(id, "secret", ct); + using var sub = list.Subscribe(); + + list.Run(id); + while ((await sub.Changes.ReadAsync(ct)).Data is not Batch { Run.Active: false }) { } + + Assert.True((await list.LogonAsync(id, null, ct))!.LogonFailed); + } + + [Fact] + public async Task An_integrated_logon_needs_no_password() + { + var id = NewBatch(SqlLogon with { Auth = Auth.Integrated, User = null }); + + var logon = await list.LogonAsync(id, null, ct); + + Assert.Equal(new SqlLogon("sql01", "Sales", "fake"), logon); + } + + [Fact] + public async Task An_unknown_batch_has_no_logon() => Assert.Null(await list.LogonAsync(99, "x", ct)); + + public void Dispose() => Directory.Delete(root, recursive: true); +} From 46bd3646778cbe3f455c64819696725039b8a0b3 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:05:27 +0200 Subject: [PATCH 38/96] test: add the SQL Server integration suite and compose.yaml --- compose.yaml | 14 ++ .../ExecutorTests.cs | 132 ++++++++++++++++++ .../SqlRunboard.Integration.Tests/RunTests.cs | 67 +++++++++ .../SqlRunboard.Integration.Tests.csproj | 12 ++ .../SqlRunboard.Integration.Tests/TestSql.cs | 26 ++++ 5 files changed, 251 insertions(+) create mode 100644 compose.yaml create mode 100644 tests/SqlRunboard.Integration.Tests/ExecutorTests.cs create mode 100644 tests/SqlRunboard.Integration.Tests/RunTests.cs create mode 100644 tests/SqlRunboard.Integration.Tests/SqlRunboard.Integration.Tests.csproj create mode 100644 tests/SqlRunboard.Integration.Tests/TestSql.cs diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..1640468 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,14 @@ +# Local SQL Server for the integration tests (docs/SPEC.md "Tests"). The image is amd64 only: +# on Apple Silicon, turn on Rosetta in Docker Desktop. +# MSSQL_SA_PASSWORD= docker compose up -d +# SQLRUNBOARD_TEST_SQL="Server=localhost,1433;User ID=sa;Password=;TrustServerCertificate=true" +# dotnet test --project tests/SqlRunboard.Integration.Tests +services: + sql: + image: mcr.microsoft.com/mssql/server:2022-latest + platform: linux/amd64 + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD:?set MSSQL_SA_PASSWORD} + ports: + - "${SQL_PORT:-1433}:1433" diff --git a/tests/SqlRunboard.Integration.Tests/ExecutorTests.cs b/tests/SqlRunboard.Integration.Tests/ExecutorTests.cs new file mode 100644 index 0000000..ac5fcf8 --- /dev/null +++ b/tests/SqlRunboard.Integration.Tests/ExecutorTests.cs @@ -0,0 +1,132 @@ +using Microsoft.Data.SqlClient; +using SqlRunboard.Core; + +/// The real adapter against the SQL Server in SQLRUNBOARD_TEST_SQL. +public class ExecutorTests +{ + readonly SqlServerExecutor executor = new(); + readonly List messages = []; + + Task Run(string text, string name = "test.sql", CancellationToken? cancel = null) => + executor.ExecuteAsync(new SqlExecution(name, SqlScript.Split(text), TestSql.Settings()), m => { lock (messages) messages.Add(m); }, + cancel ?? TestContext.Current.CancellationToken); + + string[] Texts(SqlMessageKind kind) => [.. messages.Where(m => m.Kind == kind).Select(m => m.Text)]; + + [Fact] + public async Task A_script_reports_PRINT_RAISERROR_and_rows_affected_as_messages() + { + var r = await Run(""" + PRINT 'hello' + CREATE TABLE #t (i int) + INSERT #t VALUES (1), (2) + RAISERROR('just info', 10, 1) + GO + UPDATE #t SET i = 3 + SELECT * FROM #t + """); + + Assert.Equal(ScriptStatus.Succeeded, r.Status); + Assert.Null(r.Error); + Assert.Equal(6, r.Rows); // 2 inserted + 2 updated + 2 selected (and discarded) + Assert.Equal(["hello", "just info"], Texts(SqlMessageKind.Info)); + Assert.Equal(["2 rows affected", "2 rows affected", "2 rows affected"], Texts(SqlMessageKind.RowCount)); + } + + [Fact] + public async Task A_failed_script_stops_at_its_first_failed_GO_part_and_shows_the_file_line() + { + var r = await Run(""" + PRINT 'one' + GO + PRINT 'two' + + SELECT * FROM dbo.NoSuchTable + GO + PRINT 'three' + """); + + Assert.Equal(ScriptStatus.Failed, r.Status); + Assert.Equal("line 5 (part 2): Invalid object name 'dbo.NoSuchTable'.", r.Error); + Assert.Equal(["one", "two"], Texts(SqlMessageKind.Info)); + Assert.Equal([r.Error], Texts(SqlMessageKind.Error)); + } + + [Fact] + public async Task RAISERROR_with_severity_16_fails_the_script() + { + var r = await Run("\n\nRAISERROR('bad data', 16, 1)\nPRINT 'after'\nGO\nPRINT 'next part'"); + + Assert.Equal(ScriptStatus.Failed, r.Status); + Assert.Equal("line 3 (part 1): bad data", r.Error); + Assert.DoesNotContain("next part", Texts(SqlMessageKind.Info)); + } + + [Fact] + public async Task GO_n_runs_the_part_n_times_on_one_connection() + { + var r = await Run(""" + CREATE TABLE #t (i int) + GO + INSERT #t VALUES (1) + GO 3 + IF (SELECT COUNT(*) FROM #t) <> 3 RAISERROR('wrong count', 16, 1) + """); + + Assert.Equal(ScriptStatus.Succeeded, r.Status); + Assert.Equal(3, r.Rows); + } + + [Fact] + public async Task Each_script_has_its_own_application_name_no_transaction_and_its_connection_closes_at_the_end() + { + var name = $"app-{Guid.NewGuid():N}.sql"; + var r = await Run("PRINT APP_NAME()\nPRINT @@TRANCOUNT", name); + + Assert.Equal(ScriptStatus.Succeeded, r.Status); + Assert.Equal(["SQLRunboard " + name, "0"], Texts(SqlMessageKind.Info)); + await using var c = new SqlConnection(TestSql.ConnectionString); + await c.OpenAsync(TestContext.Current.CancellationToken); + await using var q = new SqlCommand("SELECT COUNT(*) FROM sys.dm_exec_sessions WHERE program_name = @p", c); + q.Parameters.AddWithValue("@p", "SQLRunboard " + name); + Assert.Equal(0, (int)(await q.ExecuteScalarAsync(TestContext.Current.CancellationToken))!); + } + + [Fact] + public async Task A_cancel_ends_the_script_with_a_cancelled_task() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + var started = DateTime.UtcNow; + + await Assert.ThrowsAnyAsync(() => Run("WAITFOR DELAY '00:01:00'", cancel: cts.Token)); + Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(20)); + } + + [Fact] + public async Task A_wrong_password_is_a_logon_failure() + { + var settings = TestSql.Settings() with { Password = "wrong" }; + + var r = await executor.ExecuteAsync(new SqlExecution("a.sql", SqlScript.Split("PRINT 1"), settings), messages.Add, TestContext.Current.CancellationToken); + var logon = await executor.LogonAsync(settings, TestContext.Current.CancellationToken); + + Assert.Equal(ScriptStatus.Failed, r.Status); + Assert.True(r.LogonFailed); + Assert.StartsWith("Login failed for user", r.Error); + Assert.True(logon.LogonFailed); + Assert.StartsWith("Login failed for user", logon.Error); + } + + [Fact] + public async Task Test_connection_shows_the_server_name_the_database_and_the_login() + { + var target = TestSql.Settings(); + + var logon = await executor.LogonAsync(target, TestContext.Current.CancellationToken); + + Assert.Null(logon.Error); + Assert.False(string.IsNullOrEmpty(logon.Server)); + Assert.Equal(target.Target.Database, logon.Database); + Assert.Equal(target.Target.User, logon.Login); + } +} diff --git a/tests/SqlRunboard.Integration.Tests/RunTests.cs b/tests/SqlRunboard.Integration.Tests/RunTests.cs new file mode 100644 index 0000000..c7aa90d --- /dev/null +++ b/tests/SqlRunboard.Integration.Tests/RunTests.cs @@ -0,0 +1,67 @@ +using SqlRunboard.Core; + +/// The run engine with the real adapter: a logon, then real script files against SQL Server. +public sealed class RunTests : IDisposable +{ + readonly string folder = Directory.CreateTempSubdirectory("sqlrunboard-it-").FullName; + readonly BatchList list = new(new SqlServerExecutor(), TimeProvider.System); + readonly BatchList.Subscription sub; + + public RunTests() => sub = list.Subscribe(); + + async Task NewBatch(int? timeoutSeconds, params (string Path, string Text)[] files) + { + foreach (var (path, text) in files) File.WriteAllText(Path.Combine(folder, path), text); + var connection = TestSql.Settings(); + var id = list.NewBatch().Id; + list.UpdateSettings(id, new BatchSettings { Folder = folder, TimeoutSeconds = timeoutSeconds, Target = connection.Target }); + var logon = await list.LogonAsync(id, connection.Password, TestContext.Current.CancellationToken); + Assert.Null(logon!.Error); + return id; + } + + async Task UntilEnded(int id) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(60)); + while (true) + if ((await sub.Changes.ReadAsync(cts.Token)).Data is Batch { Run: { Active: false } run } b && b.Id == id) return run; + } + + static Attempt Last(RunState run, string path) => run.Scripts.Single(s => s.Path == path).Attempts[^1]; + + [Fact] + public async Task A_run_reports_success_the_file_line_of_an_error_and_the_timeout() + { + var id = await NewBatch(5, + ("ok.sql", "CREATE TABLE #t (i int)\nINSERT #t VALUES (1), (2), (3)"), + ("bad.sql", "PRINT 1\nGO\n\nSELECT 1 / 0\nGO\nPRINT 'never'"), + ("slow.sql", "WAITFOR DELAY '00:01:00'")); + + list.Run(id); + var run = await UntilEnded(id); + + Assert.Equal((ScriptStatus.Succeeded, 3L), (Last(run, "ok.sql").Status, Last(run, "ok.sql").Rows!.Value)); + Assert.Equal((ScriptStatus.Failed, "line 4 (part 2): Divide by zero error encountered."), (Last(run, "bad.sql").Status, Last(run, "bad.sql").Error)); + Assert.Equal((ScriptStatus.Failed, "Timeout after 5 s"), (Last(run, "slow.sql").Status, Last(run, "slow.sql").Error)); + } + + [Fact] + public async Task Cancel_stops_a_running_script() + { + var id = await NewBatch(null, ("slow.sql", "WAITFOR DELAY '00:01:00'")); + + list.Run(id); + await Task.Delay(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); // the script is on the server + list.Cancel(id, "slow.sql"); + var run = await UntilEnded(id); + + Assert.Equal(ScriptStatus.Cancelled, Last(run, "slow.sql").Status); + } + + public void Dispose() + { + sub.Dispose(); + Directory.Delete(folder, recursive: true); + } +} diff --git a/tests/SqlRunboard.Integration.Tests/SqlRunboard.Integration.Tests.csproj b/tests/SqlRunboard.Integration.Tests/SqlRunboard.Integration.Tests.csproj new file mode 100644 index 0000000..6fd4605 --- /dev/null +++ b/tests/SqlRunboard.Integration.Tests/SqlRunboard.Integration.Tests.csproj @@ -0,0 +1,12 @@ + + + + Exe + + + + + + + diff --git a/tests/SqlRunboard.Integration.Tests/TestSql.cs b/tests/SqlRunboard.Integration.Tests/TestSql.cs new file mode 100644 index 0000000..9bb851d --- /dev/null +++ b/tests/SqlRunboard.Integration.Tests/TestSql.cs @@ -0,0 +1,26 @@ +using Microsoft.Data.SqlClient; +using SqlRunboard.Core; + +/// The test server: a SQL Server logon in the SQLRUNBOARD_TEST_SQL connection string. No variable: the tests fail, they do not skip. +static class TestSql +{ + public static string ConnectionString => + Environment.GetEnvironmentVariable("SQLRUNBOARD_TEST_SQL") is { Length: > 0 } s ? s + : throw new InvalidOperationException("SQLRUNBOARD_TEST_SQL is not set. Set it to a SQL Server connection string with a SQL Server logon, " + + "for example \"Server=localhost,1433;User ID=sa;Password=;TrustServerCertificate=true\". compose.yaml starts a local server."); + + /// The connection string as a batch target and a password, as the page gives them. + public static SqlConnectionSettings Settings() + { + var b = new SqlConnectionStringBuilder(ConnectionString); + return new SqlConnectionSettings(new BatchTarget + { + Server = b.DataSource, + Database = b.InitialCatalog is "" ? "master" : b.InitialCatalog, + Auth = Auth.SqlServer, + User = b.UserID, + Encrypt = b.Encrypt == SqlConnectionEncryptOption.Strict ? Encrypt.Strict : b.Encrypt == SqlConnectionEncryptOption.Optional ? Encrypt.Optional : Encrypt.Mandatory, + TrustServerCertificate = b.TrustServerCertificate, + }, b.Password); + } +} From 1f217ffe5e5aa3e2fccbbc02b9f39edc31281011 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:06:09 +0200 Subject: [PATCH 39/96] feat(core): move a script in the start order, or in the queue during a run --- src/SqlRunboard.Core/Batch.cs | 3 + src/SqlRunboard.Core/BatchList.cs | 31 ++++++++++ .../ScriptOrderTests.cs | 61 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs diff --git a/src/SqlRunboard.Core/Batch.cs b/src/SqlRunboard.Core/Batch.cs index df55a45..db24e22 100644 --- a/src/SqlRunboard.Core/Batch.cs +++ b/src/SqlRunboard.Core/Batch.cs @@ -38,3 +38,6 @@ public sealed record ScriptEntry(string Path, bool Include); /// One entry in the side list. File is null for an unsaved batch. Scripts is the start order. Run is the current run, or null. public sealed record Batch(int Id, string Name, string? File, bool Dirty, BatchSettings Settings, IReadOnlyList Scripts, RunState? Run = null); + +/// The move commands of docs/SPEC.md "Script order". One item at a time. +public enum MoveTo { Up, Down, Top, Bottom } diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index 461621d..e1cb4f9 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -47,6 +47,37 @@ public Batch NewBatch() return Replace(id, b => b with { Scripts = [.. b.Scripts.Select(s => s.Path == path ? s with { Include = include } : s)] }); } + /// + /// Moves one script. During a run it moves a Queued script in the queue of that run only. Otherwise it changes the start order. + /// Null if no batch has this id. + /// + public Batch? Move(int id, string path, MoveTo to) + { + lock (gate) + { + if (Find(id) is not { } b) return null; + if (b.Run?.Active == true) + { + if (runs.GetValueOrDefault(id) is { } run && MoveIn(run.Queue, p => p == path, to)) Pump(id, run); + return Find(id); + } + var scripts = b.Scripts.ToList(); + return MoveIn(scripts, s => s.Path == path, to) ? Replace(id, x => x with { Scripts = scripts }) : b; + } + } + + /// Moves the first match. False if there is no match or it is already at that end. + static bool MoveIn(List list, Predicate match, MoveTo to) + { + var i = list.FindIndex(match); + if (i < 0) return false; + var item = list[i]; + list.RemoveAt(i); + var j = to switch { MoveTo.Top => 0, MoveTo.Bottom => list.Count, MoveTo.Up => Math.Max(0, i - 1), _ => Math.Min(list.Count, i + 1) }; + list.Insert(j, item); + return i != j; + } + /// /// Writes the batch file. Path null: Save, to the current file. Otherwise Save as, to that full path; the app adds .sqlrunboard.json if it is not there. /// Throws BatchFileChangedException if the file on disk is not the version that the app loaded, unless overwrite is true. Null if no batch has this id. diff --git a/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs b/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs new file mode 100644 index 0000000..58bbbcd --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs @@ -0,0 +1,61 @@ +using SqlRunboard.Core; + +/// docs/SPEC.md "Script order": move commands, Sort by file name, New and Missing files. Real temp folders. +public sealed class ScriptOrderTests : IDisposable +{ + readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-order-").FullName; + readonly FakeTime time = new(); + readonly FakeSqlExecutor executor; + readonly BatchList list; + + public ScriptOrderTests() + { + executor = new FakeSqlExecutor(time); + list = new BatchList(executor, time); + } + + void Touch(params string[] paths) { foreach (var p in paths) File.WriteAllText(Path.Combine(root, p), "select 1"); } + + Batch NewBatch(int limit, params string[] files) + { + Touch(files); + var b = list.NewBatch(); + return list.UpdateSettings(b.Id, new BatchSettings { Folder = root, ParallelLimit = limit })!; + } + + static string[] Order(Batch b) => [.. b.Scripts.Select(s => s.Path)]; + + [Fact] + public void Before_a_run_the_move_commands_change_the_start_order() + { + var b = NewBatch(4, "a.sql", "b.sql", "c.sql", "d.sql"); + + Assert.Equal(["a.sql", "c.sql", "b.sql", "d.sql"], Order(list.Move(b.Id, "c.sql", MoveTo.Up)!)); + Assert.Equal(["a.sql", "c.sql", "d.sql", "b.sql"], Order(list.Move(b.Id, "b.sql", MoveTo.Down)!)); + Assert.Equal(["b.sql", "a.sql", "c.sql", "d.sql"], Order(list.Move(b.Id, "b.sql", MoveTo.Top)!)); + var moved = list.Move(b.Id, "a.sql", MoveTo.Bottom)!; + Assert.Equal(["b.sql", "c.sql", "d.sql", "a.sql"], Order(moved)); + Assert.True(moved.Dirty); + + var saved = list.Save(b.Id, Path.Combine(root, "x"), overwrite: false)!; + Assert.Equal(["b.sql", "c.sql", "d.sql", "a.sql"], Order(list.Reload(saved.Id)!)); + } + + [Fact] + public void During_a_run_the_move_commands_change_the_queue_of_that_run_only() + { + var b = NewBatch(1, "a.sql", "b.sql", "c.sql", "d.sql"); + executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(60) }; + list.Run(b.Id); + var dirty = list.Move(b.Id, "a.sql", MoveTo.Bottom)!.Dirty; // a Running script does not move + + var moved = list.Move(b.Id, "d.sql", MoveTo.Top)!; + + Assert.Equal(["d.sql", "b.sql", "c.sql"], moved.Run!.Queue); + Assert.Equal(["a.sql", "b.sql", "c.sql", "d.sql"], Order(moved)); // the start order stays + Assert.Equal(dirty, moved.Dirty); + Assert.Equal(["d.sql", "b.sql", "c.sql"], list.Move(b.Id, "a.sql", MoveTo.Bottom)!.Run!.Queue); + } + + public void Dispose() => Directory.Delete(root, recursive: true); +} From 17ba6d1bcdd453ed788b0903c177b1f91fe744de Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:07:27 +0200 Subject: [PATCH 40/96] feat(core): end the run when Load again changes the target or the file scan --- src/SqlRunboard.Core/BatchList.cs | 24 +++++++++++++----------- tests/SqlRunboard.Core.Tests/RunTests.cs | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index ddc9a8e..bf0ee1d 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -36,19 +36,21 @@ public Batch NewBatch() lock (gate) { // ponytail: the scan runs under the lock; move it out if a slow network folder blocks the page. - return Replace(id, b => - { - var run = b.Run; - if (run is { Ended: false } && EndsRun(b.Settings, settings) && runs.Remove(id)) run = run with { Ended = true }; - return b with { Settings = settings, Scripts = Merge(b.Scripts, FileScan.Scan(FolderPath(b.File, settings.Folder), settings.Filters, settings.Recurse)), Run = run }; - }); + return Replace(id, b => b with { Settings = settings, Scripts = Merge(b.Scripts, FileScan.Scan(FolderPath(b.File, settings.Folder), settings.Filters, settings.Recurse)), Run = EndRun(b, settings) }); } } - /// A change to the target or the file scan ends the current run. The other settings apply to the next reruns. - static bool EndsRun(BatchSettings a, BatchSettings b) => - a.Target with { PasswordVariable = null } != b.Target with { PasswordVariable = null } - || (a.Folder, a.Filters, a.Recurse) != (b.Folder, b.Filters, b.Recurse); + /// + /// Call under the lock. A change to the target or the file scan ends the current run: reruns do nothing until the next Run. + /// The other settings apply to the next reruns. A run with Queued or Running scripts does not end. + /// + RunState? EndRun(Batch b, BatchSettings settings) + { + var (a, run) = (b.Settings, b.Run); + var ends = a.Target with { PasswordVariable = null } != settings.Target with { PasswordVariable = null } + || (a.Folder, a.Filters, a.Recurse) != (settings.Folder, settings.Filters, settings.Recurse); + return ends && run is { Ended: false, Active: false } && runs.Remove(b.Id) ? run with { Ended = true } : run; + } /// Sets or clears the check box of one file. Null if no batch has this id. public Batch? SetInclude(int id, string path, bool include) @@ -122,7 +124,7 @@ Batch Load(Batch b) var text = File.ReadAllText(b.File!); var (settings, scripts) = BatchFile.Parse(text); loaded[b.Id] = text; - return b with { Dirty = false, Settings = settings, Scripts = Merge(scripts, FileScan.Scan(FolderPath(b.File, settings.Folder), settings.Filters, settings.Recurse)) }; + return b with { Dirty = false, Settings = settings, Scripts = Merge(scripts, FileScan.Scan(FolderPath(b.File, settings.Folder), settings.Filters, settings.Recurse)), Run = EndRun(b, settings) }; } /// "Remove from list". The file stays on disk. The run of the batch is cancelled. False if no batch has this id. diff --git a/tests/SqlRunboard.Core.Tests/RunTests.cs b/tests/SqlRunboard.Core.Tests/RunTests.cs index 8ba3c5c..143eeaa 100644 --- a/tests/SqlRunboard.Core.Tests/RunTests.cs +++ b/tests/SqlRunboard.Core.Tests/RunTests.cs @@ -331,6 +331,21 @@ public async Task A_change_to_the_target_or_the_file_scan_ends_the_run_and_rerun Assert.All(next.Scripts, x => Assert.Single(x.Attempts)); } + [Fact] + public async Task Load_again_with_another_target_ends_the_run() + { + var b = NewBatch(4, "a.sql"); + list.Run(b.Id); + await Until(b.Id, r => !r.Active); + var file = Path.Combine(root, "x.sqlrunboard.json"); + b = list.Save(b.Id, file, overwrite: false)!; + Assert.False(list.Reload(b.Id)!.Run!.Ended); // the same settings: the run continues + + File.WriteAllText(file, BatchFile.Format(b.Settings with { Target = b.Settings.Target with { Server = "other" } }, b.Scripts)); + + Assert.True(list.Reload(b.Id)!.Run!.Ended); + } + public void Dispose() { sub.Dispose(); From aa77836aa5b21524216dec783c14955c5413b505 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:08:25 +0200 Subject: [PATCH 41/96] feat(core): sort by file name, keep New and Missing marks until the next save --- src/SqlRunboard.Core/Batch.cs | 11 ++++- src/SqlRunboard.Core/BatchFile.cs | 2 +- src/SqlRunboard.Core/BatchList.cs | 23 +++++++--- tests/SqlRunboard.Core.Tests/RunTests.cs | 2 +- .../ScriptOrderTests.cs | 43 +++++++++++++++++++ 5 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/SqlRunboard.Core/Batch.cs b/src/SqlRunboard.Core/Batch.cs index db24e22..6a84259 100644 --- a/src/SqlRunboard.Core/Batch.cs +++ b/src/SqlRunboard.Core/Batch.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace SqlRunboard.Core; public enum Auth { Integrated, SqlServer, EntraInteractive, EntraDefault } @@ -33,8 +35,13 @@ public sealed record BatchSettings public bool IsValid => this is { ParallelLimit: >= 1 and <= 64, TimeoutSeconds: null or >= 1, Filters: not null, Target: not null }; } -/// One file of the batch. Path is relative to the folder, with /. Include false: a cleared check box. -public sealed record ScriptEntry(string Path, bool Include); +/// +/// One file of the batch. Path is relative to the folder, with /. Include false: a cleared check box. +/// State: New or Missing until the next save (docs/SPEC.md "Script order"). The batch file never has it. +/// +public sealed record ScriptEntry(string Path, bool Include, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ScriptState? State = null); + +public enum ScriptState { New, Missing } /// One entry in the side list. File is null for an unsaved batch. Scripts is the start order. Run is the current run, or null. public sealed record Batch(int Id, string Name, string? File, bool Dirty, BatchSettings Settings, IReadOnlyList Scripts, RunState? Run = null); diff --git a/src/SqlRunboard.Core/BatchFile.cs b/src/SqlRunboard.Core/BatchFile.cs index 4507b7b..751ff6c 100644 --- a/src/SqlRunboard.Core/BatchFile.cs +++ b/src/SqlRunboard.Core/BatchFile.cs @@ -38,6 +38,6 @@ public static (BatchSettings Settings, IReadOnlyList Scripts) Parse ConfirmBeforeRun = j.ConfirmBeforeRun, TimeoutSeconds = j.TimeoutSeconds, Target = j.Target ?? new(), }; if (!settings.IsValid || j.Scripts?.Any(s => s?.Path is null) == true) throw new InvalidDataException("The batch file has an incorrect setting."); - return (settings, j.Scripts ?? []); + return (settings, [.. (j.Scripts ?? []).Select(s => new ScriptEntry(s.Path, s.Include))]); // a State in the file is not ours } } diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index e1cb4f9..896d9bf 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -66,6 +66,13 @@ public Batch NewBatch() } } + /// "Sort by file name": the start order becomes the natural sort. Null if no batch has this id. + public Batch? SortByFileName(int id) + { + lock (gate) + return Replace(id, b => b with { Scripts = [.. b.Scripts.OrderBy(s => s.Path, FileScan.NaturalOrder).ThenBy(s => s.Path, StringComparer.Ordinal)] }); + } + /// Moves the first match. False if there is no match or it is already at that end. static bool MoveIn(List list, Predicate match, MoveTo to) { @@ -95,10 +102,12 @@ static bool MoveIn(List list, Predicate match, MoveTo to) // ponytail: Save as over another existing file overwrites it without a question; ask first if users lose files that way. if (!overwrite && file == b.File && File.Exists(file) && File.ReadAllText(file) != loaded.GetValueOrDefault(id)) throw new BatchFileChangedException(file); var settings = b.Settings with { Folder = Rebase(b.File, file, b.Settings.Folder) }; - var text = BatchFile.Format(settings, b.Scripts); + // Save removes the Missing entries and clears the New marks. + List scripts = [.. b.Scripts.Where(s => s.State != ScriptState.Missing).Select(s => s with { State = null })]; + var text = BatchFile.Format(settings, scripts); File.WriteAllText(file, text); loaded[id] = text; - var batch = batches[i] = b with { Name = BatchFile.NameOf(file), File = file, Dirty = false, Settings = settings }; + var batch = batches[i] = b with { Name = BatchFile.NameOf(file), File = file, Dirty = false, Settings = settings, Scripts = scripts }; Publish(new Change("batch", batch)); return batch; } @@ -198,7 +207,7 @@ public bool Remove(int id) var scripts = Merge(b.Scripts, FileScan.Scan(folder, b.Settings.Filters, b.Settings.Recurse)); batches[i] = b with { Scripts = scripts, Dirty = b.Dirty || !scripts.SequenceEqual(b.Scripts) }; var run = runs[id] = new ActiveRun(time.GetUtcNow(), folder ?? "", b.Settings); - foreach (var s in scripts.Where(s => s.Include)) + foreach (var s in scripts.Where(s => s.Include && s.State != ScriptState.Missing)) { run.Scripts[s.Path] = new ScriptRun(s.Path, ScriptStatus.Queued, []); run.Queue.Add(s.Path); @@ -316,12 +325,16 @@ void Finish(int id, ActiveRun run, string path, DateTimeOffset start, Task string.IsNullOrWhiteSpace(folder) || batchFile is null ? folder : Path.Combine(Path.GetDirectoryName(batchFile)!, folder); - /// Known files keep their place and check box; new files go at the bottom in file-name order. Files not on disk drop out. + /// + /// Known files keep their place and check box; new files go at the bottom in file-name order, marked New. + /// Files not on disk stay, marked Missing, until the next save. + /// static IReadOnlyList Merge(IReadOnlyList known, IReadOnlyList found) { var onDisk = found.ToHashSet(StringComparer.Ordinal); var old = known.Select(s => s.Path).ToHashSet(StringComparer.Ordinal); - return [.. known.Where(s => onDisk.Contains(s.Path)), .. found.Where(p => !old.Contains(p)).Select(p => new ScriptEntry(p, Include: true))]; + return [.. known.Select(s => !onDisk.Contains(s.Path) ? s with { State = ScriptState.Missing } : s.State == ScriptState.Missing ? s with { State = null } : s), + .. found.Where(p => !old.Contains(p)).Select(p => new ScriptEntry(p, Include: true, ScriptState.New))]; } /// The snapshot and the subscription are taken under one lock, so no change is lost between them. diff --git a/tests/SqlRunboard.Core.Tests/RunTests.cs b/tests/SqlRunboard.Core.Tests/RunTests.cs index 1ef7937..876cf07 100644 --- a/tests/SqlRunboard.Core.Tests/RunTests.cs +++ b/tests/SqlRunboard.Core.Tests/RunTests.cs @@ -129,7 +129,7 @@ public void Run_scans_again_and_a_new_file_goes_at_the_bottom_with_its_check_box b = list.Run(b.Id)!; - Assert.Equal([new ScriptEntry("2.sql", true), new ScriptEntry("3.sql", false), new ScriptEntry("1.sql", true)], b.Scripts); + Assert.Equal([("2.sql", true), ("3.sql", false), ("1.sql", true)], b.Scripts.Select(s => (s.Path, s.Include))); Assert.Equal(["2.sql", "1.sql"], b.Run!.Scripts.Select(s => s.Path)); } diff --git a/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs b/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs index 58bbbcd..c3a3d5f 100644 --- a/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs +++ b/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs @@ -57,5 +57,48 @@ public void During_a_run_the_move_commands_change_the_queue_of_that_run_only() Assert.Equal(["d.sql", "b.sql", "c.sql"], list.Move(b.Id, "a.sql", MoveTo.Bottom)!.Run!.Queue); } + [Fact] + public void Sort_by_file_name_resets_the_order_to_the_natural_sort() + { + var b = NewBatch(4, "2_x.sql", "10_x.sql", "1_x.sql"); + list.Move(b.Id, "1_x.sql", MoveTo.Bottom); + list.SetInclude(b.Id, "2_x.sql", false); + + var sorted = list.SortByFileName(b.Id)!; + + Assert.Equal([("1_x.sql", true), ("2_x.sql", false), ("10_x.sql", true)], sorted.Scripts.Select(s => (s.Path, s.Include))); + } + + [Fact] + public void A_new_file_is_marked_New_and_a_missing_file_stays_with_a_warning_until_the_next_save() + { + var b = NewBatch(4, "a.sql", "b.sql"); + list.Save(b.Id, Path.Combine(root, "x"), overwrite: false); + Touch("d.sql", "c.sql"); + File.Delete(Path.Combine(root, "a.sql")); + + var scanned = list.Run(b.Id)!; + + Assert.Equal([new("a.sql", true, ScriptState.Missing), new("b.sql", true), new("c.sql", true, ScriptState.New), new("d.sql", true, ScriptState.New)], scanned.Scripts); + Assert.Equal(["b.sql", "c.sql", "d.sql"], scanned.Run!.Scripts.Select(s => s.Path)); // a missing file does not run + + var saved = list.Save(b.Id, null, overwrite: false)!; + + Assert.Equal([new("b.sql", true), new("c.sql", true), new("d.sql", true)], saved.Scripts); + Assert.DoesNotContain("a.sql", File.ReadAllText(saved.File!)); + Assert.DoesNotContain("state", File.ReadAllText(saved.File!)); + } + + [Fact] + public void A_missing_file_that_comes_back_loses_its_warning() + { + var b = NewBatch(4, "a.sql"); + File.Move(Path.Combine(root, "a.sql"), Path.Combine(root, "a.bak")); + Assert.Equal(ScriptState.Missing, list.UpdateSettings(b.Id, b.Settings)!.Scripts.Single().State); + File.Move(Path.Combine(root, "a.bak"), Path.Combine(root, "a.sql")); + + Assert.NotEqual(ScriptState.Missing, list.UpdateSettings(b.Id, b.Settings)!.Scripts.Single().State); + } + public void Dispose() => Directory.Delete(root, recursive: true); } From fb18ed8ca4d049ab641966ea21632ec9723d1532 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:08:49 +0200 Subject: [PATCH 42/96] feat(core): move a batch in the side list --- src/SqlRunboard.Core/BatchList.cs | 11 +++++++++++ tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index 896d9bf..027b7f4 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -66,6 +66,17 @@ public Batch NewBatch() } } + /// Moves a batch in the side list. SSE event "order" with the ids in the new order. False if no batch has this id. + public bool MoveBatch(int id, MoveTo to) + { + lock (gate) + { + if (Find(id) is null) return false; + if (MoveIn(batches, b => b.Id == id, to)) Publish(new Change("order", new { ids = batches.Select(b => b.Id).ToArray() })); + return true; + } + } + /// "Sort by file name": the start order becomes the natural sort. Null if no batch has this id. public Batch? SortByFileName(int id) { diff --git a/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs b/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs index c3a3d5f..4365979 100644 --- a/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs +++ b/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs @@ -100,5 +100,19 @@ public void A_missing_file_that_comes_back_loses_its_warning() Assert.NotEqual(ScriptState.Missing, list.UpdateSettings(b.Id, b.Settings)!.Scripts.Single().State); } + [Fact] + public async Task The_side_list_moves_batches_with_the_same_commands() + { + int a = list.NewBatch().Id, b = list.NewBatch().Id, c = list.NewBatch().Id; + using var sub = list.Subscribe(); + + Assert.True(list.MoveBatch(c, MoveTo.Top)); + + Assert.Equal("order", (await sub.Changes.ReadAsync(TestContext.Current.CancellationToken)).Kind); + using var after = list.Subscribe(); + Assert.Equal([c, a, b], after.Snapshot.Select(x => x.Id)); + Assert.False(list.MoveBatch(99, MoveTo.Up)); + } + public void Dispose() => Directory.Delete(root, recursive: true); } From fa01821a165e65012b54d1e1c4071ec2f6c50b94 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:12:14 +0200 Subject: [PATCH 43/96] feat(gui): send the move and sort commands to the server, keep the side list order --- src/SqlRunboard.Gui/GuiHost.cs | 8 ++ src/SqlRunboard.Gui/wwwroot/index.html | 25 ++-- .../SqlRunboard.Gui.Tests/ScriptOrderTests.cs | 111 ++++++++++++++++++ 3 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 tests/SqlRunboard.Gui.Tests/ScriptOrderTests.cs diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 7cbe75c..8ca0608 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -28,6 +28,8 @@ public sealed record PathRequest(string Path); /// Null: Save to the current file. Otherwise Save as. public sealed record SaveRequest(string? Path, bool Overwrite); public sealed record ScriptRequest(string Path); +public sealed record MoveRequest(string Path, MoveTo To); +public sealed record MoveBatchRequest(MoveTo To); /// The local web server: Kestrel on 127.0.0.1, a random port, a random token. See docs/SPEC.md "GUI host". public sealed class GuiHost : IAsyncDisposable @@ -82,6 +84,12 @@ public static async Task StartAsync(GuiHostOptions options) list.Cancel(id, r.Path) is { } b ? Results.Ok(b) : Results.NotFound()); app.MapPost("/api/batches/{id:int}/cancel-all", (int id, BatchList list) => list.CancelAll(id) is { } b ? Results.Ok(b) : Results.NotFound()); + app.MapPost("/api/batches/{id:int}/move", (int id, MoveRequest r, BatchList list) => + list.Move(id, r.Path, r.To) is { } b ? Results.Ok(b) : Results.NotFound()); + app.MapPost("/api/batches/{id:int}/sort", (int id, BatchList list) => + list.SortByFileName(id) is { } b ? Results.Ok(b) : Results.NotFound()); + app.MapPost("/api/batches/{id:int}/move-in-list", (int id, MoveBatchRequest r, BatchList list) => + list.MoveBatch(id, r.To) ? Results.Ok(new { }) : Results.NotFound()); app.MapPost("/api/batches/open", (PathRequest r, BatchList list) => FileResult(() => list.Open(r.Path))); app.MapPost("/api/batches/{id:int}/save", (int id, SaveRequest r, BatchList list) => FileResult(() => list.Save(id, r.Path, r.Overwrite))); app.MapPost("/api/batches/{id:int}/reload", (int id, BatchList list) => FileResult(() => list.Reload(id))); diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index f5d8b4c..3f4a4e4 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -239,18 +239,20 @@ } return res.json(); } +// The side list in the server order. A batch that is not in ids drops out. +const reorder = ids => { app.batches = ids.map(id => app.batches.find(b => b.id === id)).filter(Boolean); }; // SSE: the full state first ("snapshot"), then one event per change. EventSource sends the auth cookie and reconnects by itself. function connect() { const es = new EventSource('/events'); es.addEventListener('snapshot', e => { - const ids = JSON.parse(e.data).batches.map(sb => upsert(sb).id); - app.batches = app.batches.filter(b => ids.includes(b.id)); + reorder(JSON.parse(e.data).batches.map(sb => upsert(sb).id)); if (!app.batches.some(b => b.id === app.cur)) app.cur = app.batches[0]?.id ?? null; document.documentElement.dataset.connected = ''; // tests wait for this render(); }); es.addEventListener('batch', e => { upsert(JSON.parse(e.data)); render(); }); es.addEventListener('remove', e => dropBatch(JSON.parse(e.data).id)); + es.addEventListener('order', e => { reorder(JSON.parse(e.data).ids); render(); }); es.onerror = () => { delete document.documentElement.dataset.connected; }; } @@ -258,18 +260,12 @@ const DONE = ['Succeeded', 'Failed', 'Cancelled']; const lastA = r => r.attempts[r.attempts.length - 1]; const firstStart = r => Math.min(...r.attempts.filter(a => a.start != null).map(a => a.start)); -function moveIn(arr, item, dir) { - const i = arr.indexOf(item); if (i < 0) return false; - arr.splice(i, 1); - const j = dir === 'top' ? 0 : dir === 'bottom' ? arr.length : dir === 'up' ? Math.max(0, i - 1) : Math.min(arr.length, i + 1); - arr.splice(j, 0, item); return i !== j; -} const canMove = (b, path) => b.run ? !b.run.ended && b.run.rs[path]?.status === 'Queued' : !locked(b) && !!b.scripts.find(x => x.path === path); const locked = b => !!b.signing || !!(b.run && b.run.live && !b.run.ended); // ponytail: stand-ins for the commands of later tickets. Each ticket replaces its names with a POST. const todo = () => toast('Not available yet.'); -const rerun = todo, move = todo, logon = todo; +const rerun = todo, logon = todo; // Edits go to the server one at a time, in the order you make them. queued() gives the error to the caller; sendEdit() shows it. let edits = Promise.resolve(); function queued(url, body) { const p = edits.then(() => post(url, body)); edits = p.catch(() => null); return p; } @@ -285,6 +281,9 @@ } const cancel = (b, path) => sendEdit(`/api/batches/${b.id}/cancel`, { path }); const cancelAll = b => sendEdit(`/api/batches/${b.id}/cancel-all`, {}); +// The server decides: during a run it moves a Queued script in the queue, otherwise it changes the start order. dir: up, down, top, bottom. +const move = (b, path, dir) => path && sendEdit(`/api/batches/${b.id}/move`, { path, to: dir }); +const moveBatch = (b, dir) => sendEdit(`/api/batches/${b.id}/move-in-list`, { to: dir }); function setInclude(b, s, include) { s.include = include; render(); sendEdit(`/api/batches/${b.id}/include`, { path: s.path, include }); } // The folder, Open and Save as dialogs: the server lists the folders (and the batch files). kind: folder, open or saveAs. async function browse(b, path, kind = 'folder') { @@ -691,7 +690,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} case 'runlog': return todo(); case 'test': return logon(b, login => { b.testMsg = `Connected. Server ${b.settings.server.split('.')[0].toUpperCase()}, database ${b.settings.database}, login ${login}.`; }); case 'browse': return browse(b, b.settings.folder); - case 'sortFiles': b.scripts.sort((x, y) => x.path.localeCompare(y.path, undefined, { numeric: true, sensitivity: 'base' })); b.dirty = true; return render(); + case 'sortFiles': return sendEdit(`/api/batches/${b.id}/sort`, {}); } } @@ -719,7 +718,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} const dv = t.closest('[data-dview]'); if (dv) { b.dview = dv.dataset.dview; b.hlLine = null; return render(); } const ln = t.closest('[data-line]'); if (ln) { e.preventDefault(); b.dview = 'script'; b.hlLine = b.scrollLine = +ln.dataset.line; return render(); } const dm = t.closest('[data-dmove]'); if (dm) { move(b, b.sel, dm.dataset.dmove); return render(); } - const fm = t.closest('[data-fmove]'); if (fm) { const s = b.scripts.find(x => x.path === b.fileSel); if (s && moveIn(b.scripts, s, fm.dataset.fmove)) b.dirty = true; return render(); } + const fm = t.closest('[data-fmove]'); if (fm) return move(b, b.fileSel, fm.dataset.fmove); if (t.matches('[data-include]')) return; const fr = t.closest('[data-file]'); if (fr) { b.fileSel = fr.dataset.file; render(); return document.querySelector(`[data-k="f:${CSS.escape(b.fileSel)}"]`)?.focus(); } const bar = t.closest('[data-att]'); if (bar) { b.scrollAttempt = +bar.dataset.att; return selectScript(b, bar.dataset.path, true); } @@ -775,7 +774,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} const li = t.closest('#side [data-batch]'); if (li) { const lb = app.batches.find(x => x.id === +li.dataset.batch), i = app.batches.indexOf(lb); - if (e.altKey && dirs[e.key]) { e.preventDefault(); moveIn(app.batches, lb, dirs[e.key]); render(); return document.querySelector(`[data-k="b:${lb.id}"]`)?.focus(); } + if (e.altKey && dirs[e.key]) { e.preventDefault(); return moveBatch(lb, dirs[e.key]); } if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); const nb = app.batches[i + (e.key === 'ArrowDown' ? 1 : -1)]; if (nb) { selectBatch(nb.id); document.querySelector(`[data-k="b:${nb.id}"]`)?.focus(); } } if (e.key === 'Delete') removeBatch(lb); return; @@ -788,7 +787,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} const fr = t.closest('[data-file]'); if (fr && b) { const list = b.scripts.map(s => s.path), i = list.indexOf(fr.dataset.file); - if (e.altKey && dirs[e.key]) { e.preventDefault(); if (!locked(b) && moveIn(b.scripts, b.scripts[i], dirs[e.key])) b.dirty = true; render(); } + if (e.altKey && dirs[e.key]) { e.preventDefault(); if (!locked(b)) move(b, list[i], dirs[e.key]); } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); b.fileSel = list[Math.max(0, Math.min(list.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)))]; render(); } else if (e.key === ' ' && !locked(b) && b.scripts[i].state !== 'Missing') { e.preventDefault(); setInclude(b, b.scripts[i], !b.scripts[i].include); } else return; diff --git a/tests/SqlRunboard.Gui.Tests/ScriptOrderTests.cs b/tests/SqlRunboard.Gui.Tests/ScriptOrderTests.cs new file mode 100644 index 0000000..f3dafd5 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/ScriptOrderTests.cs @@ -0,0 +1,111 @@ +using Microsoft.Playwright; +using SqlRunboard.Core; + +/// docs/SPEC.md "Script order" in the browser: move commands, Sort by file name, New and Missing, side list order. +public class ScriptOrderTests : AppPageTest +{ + readonly string scripts = Directory.CreateTempSubdirectory("sqlrunboard-order-").FullName; + + ILocator Panel => Page.Locator("#panel"); + ILocator Tab(string name) => Page.GetByRole(AriaRole.Tab, new() { Name = name }); + ILocator Side => Page.GetByRole(AriaRole.Navigation, new() { Name = "Batches" }).GetByRole(AriaRole.Option); + ILocator FileNames => Panel.Locator("tbody tr td.tnum"); + ILocator Row(string path) => Panel.Locator($"tbody tr:has(td.tnum:text-is('{path}'))"); + ILocator Queued => Panel.GetByRole(AriaRole.Region, new() { Name = "Queued", Exact = true }).Locator(".card .r1 span:not(.st)"); + + void Touch(params string[] files) { foreach (var f in files) File.WriteAllText(Path.Combine(scripts, f), "select 1"); } + + Batch NewBatch(int limit, params string[] files) + { + Touch(files); + var b = App.Host.Batches.NewBatch(); + return App.Host.Batches.UpdateSettings(b.Id, new BatchSettings { Folder = scripts, ParallelLimit = limit })!; + } + + IReadOnlyList ServerBatches() { using var sub = App.Host.Batches.Subscribe(); return sub.Snapshot; } + + [Fact] + public async Task The_Files_tab_moves_and_sorts_the_start_order_and_shows_New_and_Missing_until_the_save() + { + var b = NewBatch(4, "a.sql", "b.sql", "c.sql"); + var file = App.Host.Batches.Save(b.Id, Path.Combine(scripts, "x"), overwrite: false)!.File!; + Touch("d.sql"); + File.Delete(Path.Combine(scripts, "a.sql")); + App.Host.Batches.UpdateSettings(b.Id, b.Settings with { Folder = "." }); + await Side.First.ClickAsync(); + await Expect(Tab("Files")).ToHaveAttributeAsync("aria-selected", "true"); + + await Expect(FileNames).ToHaveTextAsync(["a.sql", "b.sql", "c.sql", "d.sql"]); + await Expect(Row("a.sql")).ToContainTextAsync("Missing on disk. The next save removes it."); + await Expect(Row("d.sql")).ToContainTextAsync("New"); + await Expect(Row("b.sql")).Not.ToContainTextAsync("New"); + + await Row("c.sql").ClickAsync(); + await Panel.GetByRole(AriaRole.Button, new() { Name = "Move to top" }).ClickAsync(); + await Expect(FileNames).ToHaveTextAsync(["c.sql", "a.sql", "b.sql", "d.sql"]); + await Row("c.sql").PressAsync("Alt+End"); + await Expect(FileNames).ToHaveTextAsync(["a.sql", "b.sql", "d.sql", "c.sql"]); + await Row("c.sql").PressAsync("Alt+ArrowUp"); + await Expect(FileNames).ToHaveTextAsync(["a.sql", "b.sql", "c.sql", "d.sql"]); + await Row("a.sql").ClickAsync(); + await Row("a.sql").PressAsync("Alt+ArrowDown"); + await Expect(FileNames).ToHaveTextAsync(["b.sql", "a.sql", "c.sql", "d.sql"]); + Assert.Equal(["b.sql", "a.sql", "c.sql", "d.sql"], ServerBatches().Single().Scripts.Select(s => s.Path)); + + await Row("d.sql").ClickAsync(); + await Page.Keyboard.PressAsync("Alt+Home"); + await Expect(FileNames).ToHaveTextAsync(["d.sql", "b.sql", "a.sql", "c.sql"]); + await Panel.GetByRole(AriaRole.Button, new() { Name = "Sort by file name" }).ClickAsync(); + await Expect(FileNames).ToHaveTextAsync(["a.sql", "b.sql", "c.sql", "d.sql"]); + + await Page.Keyboard.PressAsync("ControlOrMeta+s"); + await Expect(FileNames).ToHaveTextAsync(["b.sql", "c.sql", "d.sql"]); + await Expect(Panel.Locator(".badge")).ToHaveCountAsync(0); + Assert.DoesNotContain("a.sql", File.ReadAllText(file)); + } + + [Fact] + public async Task During_a_run_the_card_menu_and_the_keys_move_Queued_scripts_for_that_run_only() + { + var b = NewBatch(1, "a.sql", "b.sql", "c.sql", "d.sql"); + App.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(60) }; + App.Host.Batches.Run(b.Id); + await Side.First.ClickAsync(); + await Expect(Queued).ToHaveTextAsync(["b.sql", "c.sql", "d.sql"]); + + await Queued.Filter(new() { HasText = "d.sql" }).ClickAsync(new() { Button = MouseButton.Right }); + await Page.GetByRole(AriaRole.Menuitem, new() { Name = "Move to top" }).ClickAsync(); + await Expect(Queued).ToHaveTextAsync(["d.sql", "b.sql", "c.sql"]); + + await Panel.GetByRole(AriaRole.Button, new() { Name = "Table" }).ClickAsync(); + var row = Panel.Locator("tbody tr:has-text('c.sql')"); + await row.ClickAsync(); + await row.PressAsync("Alt+Home"); + await Expect(Panel.Locator("tbody tr td.tnum")).ToHaveTextAsync(["a.sql", "c.sql", "d.sql", "b.sql"]); // queue order + + Assert.Equal(["a.sql", "b.sql", "c.sql", "d.sql"], ServerBatches().Single().Scripts.Select(s => s.Path)); // the start order stays + } + + [Fact] + public async Task The_side_list_moves_batches_with_the_same_keys() + { + App.Host.Batches.NewBatch(); App.Host.Batches.NewBatch(); App.Host.Batches.NewBatch(); + await Expect(Side).ToHaveCountAsync(3); + + await Side.Nth(2).ClickAsync(); + await Side.Nth(2).PressAsync("Alt+ArrowUp"); + await Expect(Side.Locator(".n")).ToHaveTextAsync(["Untitled 1 • (unsaved)", "Untitled 3 • (unsaved)", "Untitled 2 • (unsaved)"]); + await Side.Nth(0).ClickAsync(); + await Side.Nth(0).PressAsync("Alt+End"); + await Expect(Side.Locator(".n")).ToHaveTextAsync(["Untitled 3 • (unsaved)", "Untitled 2 • (unsaved)", "Untitled 1 • (unsaved)"]); + + await OpenAsync(Page, App.Host.BaseUrl); // the server keeps the order + await Expect(Side.Locator(".n")).ToHaveTextAsync(["Untitled 3 • (unsaved)", "Untitled 2 • (unsaved)", "Untitled 1 • (unsaved)"]); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + Directory.Delete(scripts, recursive: true); + } +} From 21c3b783cd279fee01a491763f308923786ba62a Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:13:15 +0200 Subject: [PATCH 44/96] feat(gui): ask for the password, check the logon before a run and show Test connection --- src/SqlRunboard.Gui/GuiHost.cs | 3 + src/SqlRunboard.Gui/wwwroot/index.html | 32 ++++-- tests/SqlRunboard.Gui.Tests/LogonTests.cs | 119 ++++++++++++++++++++++ 3 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 tests/SqlRunboard.Gui.Tests/LogonTests.cs diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 205242a..ec22de2 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -21,6 +21,7 @@ public sealed record GuiHostOptions(string AppDataFolder, ISqlExecutor Executor, public sealed record IncludeRequest(string Path, bool Include); public sealed record FolderRequest(string? Path); +public sealed record LogonRequest(string? Password); public sealed record ScriptRequest(string Path); /// The local web server: Kestrel on 127.0.0.1, a random port, a random token. See docs/SPEC.md "GUI host". @@ -70,6 +71,8 @@ public static async Task StartAsync(GuiHostOptions options) : list.UpdateSettings(id, settings) is { } b ? Results.Ok(b) : Results.NotFound()); app.MapPost("/api/batches/{id:int}/include", (int id, IncludeRequest r, BatchList list) => list.SetInclude(id, r.Path, r.Include) is { } b ? Results.Ok(b) : Results.NotFound()); + app.MapPost("/api/batches/{id:int}/logon", async (int id, LogonRequest r, BatchList list, CancellationToken ct) => + await list.LogonAsync(id, r.Password, ct) is { } logon ? Results.Ok(logon) : Results.NotFound()); app.MapPost("/api/batches/{id:int}/run", (int id, BatchList list) => list.Run(id) is { } b ? Results.Ok(b) : Results.NotFound()); app.MapPost("/api/batches/{id:int}/cancel", (int id, ScriptRequest r, BatchList list) => diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index e96755c..c9536e7 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -266,7 +266,7 @@ // ponytail: stand-ins for the commands of later tickets. Each ticket replaces its names with a POST. const todo = () => toast('Not available yet.'); -const rerun = todo, move = todo, logon = todo, saveBatch = todo, openBatch = todo, removeBatch = todo; +const rerun = todo, move = todo, saveBatch = todo, openBatch = todo, removeBatch = todo; // Edits go to the server one at a time, in the order you make them. let edits = Promise.resolve(); const sendEdit = (url, body) => edits = edits.then(() => post(url, body)).catch(e => { toast(e.message); return null; }); @@ -274,10 +274,26 @@ b.saving = (b.saving ?? 0) + 1; sendEdit(`/api/batches/${b.id}/settings`, fromView(b.settings)).then(sb => { if (--b.saving) return; if (sb) upsert(sb); render(); }); } -async function runBatch(b) { +// The logon check before a run, and Test connection. The server keeps the password; the page only passes it on from the dialog. +async function logon(b, then, password = null) { + if (b.signing) return; + const token = {}; + b.logonMsg = null; b.testMsg = null; b.signing = { token, text: 'Signing in...' }; + render(); + const r = await sendEdit(`/api/batches/${b.id}/logon`, { password }); + if (b.signing?.token !== token) return; // cancelled: ignore the result + b.signing = null; + if (r?.logonFailed && b.settings.auth === 'SqlServer') openDialog({ kind: 'password', b, then, error: r.error }); + else if (r?.error) b.logonMsg = r.error; + else if (r) then(r); + render(); +} +function runBatch(b) { if (!b || b.missing || locked(b)) return; - const sb = await sendEdit(`/api/batches/${b.id}/run`, {}); - if (sb) { upsert(sb); b.tab = 'run'; render(); } + logon(b, async () => { + const sb = await sendEdit(`/api/batches/${b.id}/run`, {}); + if (sb) { upsert(sb); b.tab = 'run'; render(); } + }); } const cancel = (b, path) => sendEdit(`/api/batches/${b.id}/cancel`, { path }); const cancelAll = b => sendEdit(`/api/batches/${b.id}/cancel-all`, {}); @@ -498,8 +514,8 @@

Run

The total time of one attempt, for all parts. ${chk('stopOnFirstFailure', 'Stop the queue at the first failure')}${chk('confirmBeforeRun', 'Confirm before run')}

Target

${txt('server', 'Server')}${txt('database', 'Database')} - ${sel('auth', 'Logon', [['Integrated', 'Integrated (Windows or Kerberos)'], ['SqlServer', 'SQL Server'], ['EntraInteractive', 'Microsoft Entra ID, interactive'], ['EntraDefault', 'Microsoft Entra ID, default']])} - ${s.auth === 'SqlServer' || s.auth === 'EntraInteractive' ? txt('user', s.auth === 'SqlServer' ? 'User' : 'Account hint') : ''} + ${sel('auth', 'Logon', [['Integrated', 'Integrated (Windows or Kerberos)'], ['SqlServer', 'SQL Server']])} + ${s.auth === 'SqlServer' ? txt('user', 'User') : ''} ${s.auth === 'SqlServer' ? '
The app asks for the password at the first run and keeps it in memory.
' : ''} ${sel('encrypt', 'Encrypt', [['Mandatory', 'Mandatory'], ['Optional', 'Optional'], ['Strict', 'Strict']])} ${chk('trustServerCertificate', 'Trust server certificate')}${txt('extra', 'Additional connection string')} @@ -598,7 +614,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} } function dialogOk() { const d = app.dialog, v = document.getElementById('dlg-in')?.value ?? ''; - if (d.kind === 'password') { if (!v) return; d.b.password = v; app.dialog = null; renderDialog(); logon(d.b, d.then); return; } + if (d.kind === 'password') { if (!v) return; app.dialog = null; renderDialog(); logon(d.b, d.then, v); return; } if (d.kind === 'folder') { d.b.settings.folder = d.path; saveSettings(d.b); closeDialog(); return; } if (d.kind === 'confirm') { app.dialog = null; renderDialog(); d.then(); render(); return; } if (d.kind === 'saveAs') { @@ -654,7 +670,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} case 'dRerun': rerun(b, [b.sel]); return render(); case 'closeDrawer': b.drawer = false; render(); return document.querySelector(`[data-k="s:${CSS.escape(b.sel)}"]`)?.focus(); case 'runlog': return todo(); - case 'test': return logon(b, login => { b.testMsg = `Connected. Server ${b.settings.server.split('.')[0].toUpperCase()}, database ${b.settings.database}, login ${login}.`; }); + case 'test': return logon(b, r => { b.testMsg = `Connected. Server ${r.server}, database ${r.database}, login ${r.login}.`; }); case 'browse': return browse(b, b.settings.folder); case 'sortFiles': b.scripts.sort((x, y) => x.path.localeCompare(y.path, undefined, { numeric: true, sensitivity: 'base' })); b.dirty = true; return render(); } diff --git a/tests/SqlRunboard.Gui.Tests/LogonTests.cs b/tests/SqlRunboard.Gui.Tests/LogonTests.cs new file mode 100644 index 0000000..5e76f49 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/LogonTests.cs @@ -0,0 +1,119 @@ +using Microsoft.Playwright; +using SqlRunboard.Core; + +/// The password dialog and Test connection, with the fake executor. +public class LogonTests : AppPageTest +{ + readonly string scripts = Directory.CreateTempSubdirectory("sqlrunboard-scripts-").FullName; + static readonly BatchTarget SqlLogon = new() { Server = "sql01", Database = "Sales", Auth = Auth.SqlServer, User = "deploy" }; + + ILocator Panel => Page.Locator("#panel"); + ILocator Header => Page.Locator("#hdr"); + ILocator Dialog => Page.GetByRole(AriaRole.Dialog); + ILocator RunButton => Header.GetByRole(AriaRole.Button, new() { Name = "Run", Exact = true }); + + async Task OpenBatchAsync(BatchTarget target) + { + File.WriteAllText(Path.Combine(scripts, "a.sql"), "select 1"); + App.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(10) }; + var b = App.Host.Batches.NewBatch(); + b = App.Host.Batches.UpdateSettings(b.Id, new BatchSettings { Folder = scripts, Target = target })!; + await Page.GetByRole(AriaRole.Navigation, new() { Name = "Batches" }).Locator($"[role=option]:has-text('{b.Name}')").ClickAsync(); + return b; + } + + async Task SignInAsync(string password) + { + await Dialog.GetByLabel("Password").FillAsync(password); + await Dialog.GetByRole(AriaRole.Button, new() { Name = "Sign in" }).ClickAsync(); + } + + async Task RunAndWaitAsync(int runs) + { + // The script runs until the clock moves, so a late run response cannot show it Running again after the end. + await Expect(Panel.GetByRole(AriaRole.Region, new() { Name = "Running", Exact = true }).Locator(".card")).ToHaveCountAsync(1); + App.Time.Advance(TimeSpan.FromSeconds(10)); + await Expect(RunButton).ToBeEnabledAsync(); + Assert.Equal(runs, App.Executor.Calls.Count); + return [.. App.Executor.Calls.Select(c => c.Connection.Password)]; + } + + [Fact] + public async Task The_first_run_asks_for_the_password_and_the_next_run_does_not() + { + await OpenBatchAsync(SqlLogon); + + await RunButton.ClickAsync(); + await Expect(Dialog).ToContainTextAsync("Sign in to sql01"); + await SignInAsync("secret"); + Assert.Equal(["secret"], await RunAndWaitAsync(1)); + + await RunButton.ClickAsync(); + Assert.Equal(["secret", "secret"], await RunAndWaitAsync(2)); + await Expect(Dialog).ToHaveCountAsync(0); + Assert.Equal(0, await Page.EvaluateAsync("() => JSON.stringify(app).split('secret').length - 1")); // the page keeps no password + } + + [Fact] + public async Task A_rejected_password_asks_again_with_the_error() + { + await OpenBatchAsync(SqlLogon); + App.Executor.Logon = c => c.Password == "wrong" ? new SqlLogon(null, null, null, "Login failed for user 'deploy'.", LogonFailed: true) : new SqlLogon("SQL01", "Sales", "deploy"); + + await RunButton.ClickAsync(); + await SignInAsync("wrong"); + await Expect(Dialog.GetByRole(AriaRole.Alert)).ToHaveTextAsync("Login failed for user 'deploy'. Type the password again."); + await SignInAsync("right"); + + Assert.Equal(["right"], await RunAndWaitAsync(1)); + } + + [Fact] + public async Task A_target_change_asks_for_the_password_again() + { + await OpenBatchAsync(SqlLogon); + await RunButton.ClickAsync(); + await SignInAsync("secret"); + await RunAndWaitAsync(1); + + await Page.GetByRole(AriaRole.Tab, new() { Name = "Settings" }).ClickAsync(); + await Panel.GetByLabel("Database").FillAsync("Other"); + await Panel.GetByLabel("Database").PressAsync("Enter"); + await RunButton.ClickAsync(); + + await Expect(Dialog).ToContainTextAsync("Other"); + } + + [Fact] + public async Task Test_connection_shows_the_server_name_and_the_database() + { + await OpenBatchAsync(SqlLogon with { Auth = Auth.Integrated }); + App.Executor.Logon = c => new SqlLogon("SQL01\\PROD", "Sales", "EXAMPLE\\hank"); + + await Page.GetByRole(AriaRole.Tab, new() { Name = "Settings" }).ClickAsync(); + await Panel.GetByRole(AriaRole.Button, new() { Name = "Test connection" }).ClickAsync(); + + await Expect(Panel.GetByRole(AriaRole.Status)).ToHaveTextAsync("Connected. Server SQL01\\PROD, database Sales, login EXAMPLE\\hank."); + } + + [Fact] + public async Task A_logon_error_shows_a_banner_and_does_not_run() + { + await OpenBatchAsync(SqlLogon with { Auth = Auth.Integrated }); + App.Executor.Logon = c => new SqlLogon(null, null, null, "Cannot authenticate using Kerberos."); + + await RunButton.ClickAsync(); + + await Expect(Page.GetByRole(AriaRole.Alert)).ToHaveTextAsync("Cannot authenticate using Kerberos."); + Assert.Empty(App.Executor.Calls); + } + + [Fact] + public async Task The_Settings_tab_offers_only_Integrated_and_SQL_Server_logon() + { + await OpenBatchAsync(SqlLogon); + await Page.GetByRole(AriaRole.Tab, new() { Name = "Settings" }).ClickAsync(); + + await Expect(Panel.GetByLabel("Logon").Locator("option")).ToHaveTextAsync(["Integrated (Windows or Kerberos)", "SQL Server"]); + } +} From 407e36f42bc5ea954b0cc443f84f34c4d291d88a Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:13:15 +0200 Subject: [PATCH 45/96] feat: run scripts with the SqlClient executor in both executables --- src/sqlrunboard-desktop/Program.cs | 4 +--- src/sqlrunboard/Program.cs | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/sqlrunboard-desktop/Program.cs b/src/sqlrunboard-desktop/Program.cs index 4cbd80b..7f14db7 100644 --- a/src/sqlrunboard-desktop/Program.cs +++ b/src/sqlrunboard-desktop/Program.cs @@ -21,9 +21,7 @@ var startUrl = GuiHost.FindRunningAsync(appData).GetAwaiter().GetResult(); if (startUrl is null) { - var time = TimeProvider.System; - // ponytail: the fake executor runs until the real SQL Server adapter (ticket #7) replaces it. - host = GuiHost.StartAsync(new GuiHostOptions(appData, new FakeSqlExecutor(time), time)).GetAwaiter().GetResult(); + host = GuiHost.StartAsync(new GuiHostOptions(appData, new SqlServerExecutor(), TimeProvider.System)).GetAwaiter().GetResult(); startUrl = host.StartUrl; } diff --git a/src/sqlrunboard/Program.cs b/src/sqlrunboard/Program.cs index 6cd8a27..3352808 100644 --- a/src/sqlrunboard/Program.cs +++ b/src/sqlrunboard/Program.cs @@ -23,9 +23,7 @@ return 0; } -var time = TimeProvider.System; -// ponytail: the fake executor runs until the real SQL Server adapter (ticket #7) replaces it. -await using var host = await GuiHost.StartAsync(new GuiHostOptions(appData, new FakeSqlExecutor(time), time)); +await using var host = await GuiHost.StartAsync(new GuiHostOptions(appData, new SqlServerExecutor(), TimeProvider.System)); Open(host.StartUrl); await host.WaitForShutdownAsync(); // Ctrl+C stops the server return 0; From ca02c770c18e6df908de9a21acae32b9ef4e44a8 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:13:31 +0200 Subject: [PATCH 46/96] feat(core): write run.log and summary.csv in a log folder for each run --- src/SqlRunboard.Core/BatchList.cs | 28 ++- src/SqlRunboard.Core/RunLog.cs | 106 +++++++++++ tests/SqlRunboard.Core.Tests/FakeTime.cs | 2 + tests/SqlRunboard.Core.Tests/RunLogTests.cs | 195 ++++++++++++++++++++ 4 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 src/SqlRunboard.Core/RunLog.cs create mode 100644 tests/SqlRunboard.Core.Tests/RunLogTests.cs diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index 461621d..f42e8c7 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -9,8 +9,9 @@ public sealed record Change(string Kind, object Data); /// The side list: all batches, in the order you added them. The server keeps this state; the page shows it. /// Each subscriber gets the full state, then every change after it. /// It also runs the batches: each batch has its own queue and parallel limit, and many batches can run at once. +/// Each run writes a log folder next to the batch file, or for an unsaved batch in appDataFolder (no log if it is null). ///
-public sealed class BatchList(ISqlExecutor executor, TimeProvider time) +public sealed class BatchList(ISqlExecutor executor, TimeProvider time, string? appDataFolder = null) { readonly Lock gate = new(); readonly List batches = []; @@ -166,7 +167,10 @@ public bool Remove(int id) var folder = FolderPath(b.File, b.Settings.Folder); var scripts = Merge(b.Scripts, FileScan.Scan(folder, b.Settings.Filters, b.Settings.Recurse)); batches[i] = b with { Scripts = scripts, Dirty = b.Dirty || !scripts.SequenceEqual(b.Scripts) }; - var run = runs[id] = new ActiveRun(time.GetUtcNow(), folder ?? "", b.Settings); + var logParent = b.File is null ? appDataFolder : Path.GetDirectoryName(b.File); + // ponytail: a log folder that cannot be made (read-only folder) stops the Run with the IO error; run without logs if users need that. + var log = logParent is null ? null : RunLog.Create(logParent, b.Name, time, text => Warn(id, text)); + var run = runs[id] = new ActiveRun(time.GetUtcNow(), folder ?? "", b.Settings, log); foreach (var s in scripts.Where(s => s.Include)) { run.Scripts[s.Path] = new ScriptRun(s.Path, ScriptStatus.Queued, []); @@ -208,8 +212,9 @@ public bool Remove(int id) } /// The mutable state of a run. Change it only under the lock. - sealed class ActiveRun(DateTimeOffset start, string folder, BatchSettings settings) + sealed class ActiveRun(DateTimeOffset start, string folder, BatchSettings settings, RunLog? log) { + public RunLog? Log => log; public DateTimeOffset Start => start; public string Folder => folder; public BatchSettings Settings => settings; @@ -245,17 +250,21 @@ void Pump(int id, ActiveRun run) catch (Exception e) when (e is IOException or UnauthorizedAccessException) { run.Add(path, new Attempt(start, start, ScriptStatus.Failed, null, e is FileNotFoundException or DirectoryNotFoundException ? "File not found" : e.Message)); + run.Log?.Append($"{Attempt(run, path)}: Failed: {run.Scripts[path].Attempts[^1].Error}"); run.Failed(); continue; } run.Running++; run.Add(path, new Attempt(start, null, ScriptStatus.Running, null, null)); + var attempt = Attempt(run, path); + run.Log?.Append($"{attempt}: Started"); var execution = new SqlExecution(path, parts, new SqlConnectionSettings(run.Settings.Target, Password: null)); // The continuation runs on the thread pool, never inline here under the lock. var cts = run.Tokens[path] = run.Settings.TimeoutSeconds is int s ? new CancellationTokenSource(TimeSpan.FromSeconds(s), time) : new CancellationTokenSource(); - _ = executor.ExecuteAsync(execution, _ => { }, cts.Token) + _ = executor.ExecuteAsync(execution, m => run.Log?.Append($"{attempt}: {(m.Kind == SqlMessageKind.Error ? "Error: " : "")}{m.Text}"), cts.Token) .ContinueWith(t => Finish(id, run, path, start, t), TaskScheduler.Default); } + run.Log?.Update(run.Scripts.Values, run.Settings.Target); // also for a run that is no longer current: its late ends go to its log var i = batches.FindIndex(b => b.Id == id); if (i < 0 || runs.GetValueOrDefault(id) != run) return; var state = new RunState(run.Start, time.GetUtcNow(), [.. run.Scripts.Values], [.. run.Queue]); @@ -273,12 +282,23 @@ void Finish(int id, ActiveRun run, string path, DateTimeOffset start, Task"1.sql #2": the script and the number of its last attempt, for run.log. + static string Attempt(ActiveRun run, string path) => $"{path} #{run.Scripts[path].Attempts.Count}"; + + /// A log write failed. The page shows the text; the run continues. + void Warn(int id, string text) + { + lock (gate) Publish(new Change("warning", new { id, text })); + } + Batch? Find(int id) => batches.Find(b => b.Id == id); /// A relative folder is relative to the batch file. An unsaved batch has an absolute folder. diff --git a/src/SqlRunboard.Core/RunLog.cs b/src/SqlRunboard.Core/RunLog.cs new file mode 100644 index 0000000..f9eb4c1 --- /dev/null +++ b/src/SqlRunboard.Core/RunLog.cs @@ -0,0 +1,106 @@ +using System.Globalization; +using System.Text; + +namespace SqlRunboard.Core; + +/// +/// The log folder of one run: run.log (all messages of all attempts, a summary at each end of the run) and summary.csv +/// (written again at each status change, to a temporary file and then with a rename). Thread-safe. +/// A write that fails does not stop the run: it calls warn and is tried again later. +/// +sealed class RunLog +{ + public const string LogFile = "run.log", SummaryFile = "summary.csv"; + static readonly Encoding Utf8Bom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); // Excel reads UTF-8 only with a BOM + + readonly string folder; + readonly TimeProvider time; + readonly Action warn; + readonly Lock gate = new(); + bool active = true, warned; + + RunLog(string folder, TimeProvider time, Action warn) => (this.folder, this.time, this.warn) = (folder, time, warn); + + /// Makes the folder logs/<batch>_<yyyyMMdd-HHmmss> under parent. If the name exists, it adds -2, -3 and so on. + public static RunLog Create(string parent, string batchName, TimeProvider time, Action warn) + { + var name = Path.Combine(parent, "logs", $"{batchName}_{Local(time.GetUtcNow(), time):yyyyMMdd-HHmmss}"); + var folder = name; + for (var n = 2; Directory.Exists(folder); n++) folder = $"{name}-{n}"; + Directory.CreateDirectory(folder); + return new RunLog(folder, time, warn); + } + + /// Adds one line to run.log, with the local time. + public void Append(string text) + { + string? error = null; + lock (gate) + { + // ponytail: one open and close for each line; buffer it if many messages make the run slow. + try { File.AppendAllText(Path.Combine(folder, LogFile), $"{Format(time.GetUtcNow())} {text}{Environment.NewLine}"); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { error = e.Message; } + } + // Outside the lock: warn takes the BatchList lock, and a message thread must not hold this lock then. + if (error is not null) warn($"Warning: cannot write {LogFile}: {error}"); + } + + /// Call at each status change. Writes summary.csv again, and the summary in run.log when the run ends. + public void Update(IReadOnlyCollection scripts, BatchTarget target) + { + lock (gate) + { + WriteSummary(scripts, target); + var now = scripts.Any(s => s.Status is ScriptStatus.Queued or ScriptStatus.Running); + if (active && !now) AppendSummary(scripts); + active = now; + } + } + + void WriteSummary(IReadOnlyCollection scripts, BatchTarget target) + { + var csv = new StringBuilder("script,attempt,status,start,end,duration,rows,error,server,database\r\n"); + void Row(string path, int n, ScriptStatus status, Attempt? a, string? error) => + csv.AppendJoin(',', [Csv(path), n.ToString(CultureInfo.InvariantCulture), status.ToString(), + a is null ? "" : Format(a.Start), a?.End is { } end ? Format(end) : "", + a?.End is { } e ? (e - a.Start).TotalSeconds.ToString("F3", CultureInfo.InvariantCulture) : "", + a?.Rows?.ToString(CultureInfo.InvariantCulture) ?? "", Csv(error), Csv(target.Server), Csv(target.Database)]).Append("\r\n"); + foreach (var s in scripts) + if (s.Attempts.Count == 0) Row(s.Path, 0, s.Status, null, s.Error); + else for (var i = 0; i < s.Attempts.Count; i++) Row(s.Path, i + 1, s.Attempts[i].Status, s.Attempts[i], s.Attempts[i].Error); + + var file = Path.Combine(folder, SummaryFile); + try + { + File.WriteAllText(file + ".tmp", csv.ToString(), Utf8Bom); + File.Move(file + ".tmp", file, overwrite: true); + warned = false; + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + if (warned) return; // one warning until a write works again + warned = true; + var text = $"Warning: cannot write {SummaryFile}: {e.Message} The run continues. The app tries again at the next status change."; + Append(text); + warn(text); + } + } + + void AppendSummary(IReadOnlyCollection scripts) + { + int Count(ScriptStatus s) => scripts.Count(x => x.Status == s); + Append($"Run ended: {scripts.Count} scripts, {Count(ScriptStatus.Succeeded)} succeeded, {Count(ScriptStatus.Failed)} failed, {Count(ScriptStatus.Cancelled)} cancelled."); + foreach (var s in scripts) + { + var last = s.Attempts.LastOrDefault(); + Append($" {s.Path}: {s.Status}, attempts: {s.Attempts.Count}" + (last?.Rows is { } r ? $", rows: {r}" : "") + ((last?.Error ?? s.Error) is { } err ? $", error: {err}" : "")); + } + } + + static string Csv(string? v) => v is null ? "" : v.IndexOfAny([',', '"', '\r', '\n']) >= 0 ? $"\"{v.Replace("\"", "\"\"")}\"" : v; + + static DateTimeOffset Local(DateTimeOffset t, TimeProvider time) => TimeZoneInfo.ConvertTime(t, time.LocalTimeZone); + + /// Local time with the UTC offset, for example 2026-10-09T14:03:12+02:00. + string Format(DateTimeOffset t) => Local(t, time).ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture); +} diff --git a/tests/SqlRunboard.Core.Tests/FakeTime.cs b/tests/SqlRunboard.Core.Tests/FakeTime.cs index 15d752d..68ec1fc 100644 --- a/tests/SqlRunboard.Core.Tests/FakeTime.cs +++ b/tests/SqlRunboard.Core.Tests/FakeTime.cs @@ -8,6 +8,8 @@ public sealed class FakeTime(DateTimeOffset start) : TimeProvider public FakeTime() : this(new DateTimeOffset(2026, 10, 9, 12, 0, 0, TimeSpan.Zero)) { } public override DateTimeOffset GetUtcNow() { lock (gate) return now; } + /// A fixed +02:00 zone (no DST), so local times in the logs are the same on every computer. + public override TimeZoneInfo LocalTimeZone { get; } = TimeZoneInfo.CreateCustomTimeZone("FakeTime +02", TimeSpan.FromHours(2), "FakeTime +02", "FakeTime +02"); public override long TimestampFrequency => TimeSpan.TicksPerSecond; public override long GetTimestamp() => GetUtcNow().UtcTicks; diff --git a/tests/SqlRunboard.Core.Tests/RunLogTests.cs b/tests/SqlRunboard.Core.Tests/RunLogTests.cs new file mode 100644 index 0000000..01ac2d0 --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/RunLogTests.cs @@ -0,0 +1,195 @@ +using SqlRunboard.Core; + +/// The log folder of a run: run.log and summary.csv. Seen through BatchList and the files on disk. FakeTime local zone is +02:00. +public sealed class RunLogTests : IDisposable +{ + readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-log-").FullName; + readonly string appData; + readonly FakeTime time = new(); + readonly FakeSqlExecutor executor; + readonly BatchList list; + readonly BatchList.Subscription sub; + + public RunLogTests() + { + appData = Directory.CreateDirectory(Path.Combine(root, "appdata")).FullName; + executor = new FakeSqlExecutor(time); + list = new BatchList(executor, time, appData); + sub = list.Subscribe(); + } + + public void Dispose() + { + sub.Dispose(); + Directory.Delete(root, recursive: true); + } + + string Scripts => Directory.CreateDirectory(Path.Combine(root, "scripts")).FullName; + + void Script(string path, int seconds, ScriptStatus status = ScriptStatus.Succeeded, long? rows = null, string? error = null, params string[] messages) => + executor.Scripts[path] = new FakeScript + { + Duration = TimeSpan.FromSeconds(seconds), Status = status, Rows = rows, Error = error, + Messages = [.. messages.Select(m => new SqlMessage(SqlMessageKind.Info, m))], + }; + + Batch NewBatch(BatchSettings settings, params string[] files) + { + foreach (var f in files) File.WriteAllText(Path.Combine(Scripts, f), "select 1"); + var b = list.NewBatch(); + return list.UpdateSettings(b.Id, settings with { Folder = Scripts })!; + } + + async Task Until(int id, Func ok) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (true) + { + var change = await sub.Changes.ReadAsync(cts.Token); + if (change.Data is Batch { Run: { } run } b && b.Id == id && ok(run)) return run; + } + } + + static bool Ended(RunState r) => !r.Active; + + [Fact] + public async Task An_unsaved_batch_writes_summary_csv_in_the_app_data_folder_with_local_times() + { + var target = new BatchTarget { Server = "sql01", Database = "Sales" }; + var b = NewBatch(new BatchSettings { ParallelLimit = 1, StopOnFirstFailure = true, Target = target }, "1.sql", "2.sql", "3.sql"); + Script("1.sql", 12, rows: 5); + Script("2.sql", 3, ScriptStatus.Failed, error: "line 4 (part 1): Invalid object name \"x, y\""); + + list.Run(b.Id); + await Until(b.Id, r => r.Scripts[0].Status == ScriptStatus.Running); + time.Advance(TimeSpan.FromSeconds(12)); + await Until(b.Id, r => r.Scripts[1].Status == ScriptStatus.Running); + time.Advance(TimeSpan.FromSeconds(3)); + await Until(b.Id, Ended); + + var folder = Path.Combine(appData, "logs", "Untitled 1_20261009-140000"); + Assert.Equal( + """" + script,attempt,status,start,end,duration,rows,error,server,database + 1.sql,1,Succeeded,2026-10-09T14:00:00+02:00,2026-10-09T14:00:12+02:00,12.000,5,,sql01,Sales + 2.sql,1,Failed,2026-10-09T14:00:12+02:00,2026-10-09T14:00:15+02:00,3.000,,"line 4 (part 1): Invalid object name ""x, y""",sql01,Sales + 3.sql,0,Cancelled,,,,,Not started: the queue stopped,sql01,Sales + + """".ReplaceLineEndings("\r\n"), + File.ReadAllText(Path.Combine(folder, "summary.csv"))); + } + + [Fact] + public async Task Run_log_has_all_messages_and_a_summary_at_the_end() + { + var b = NewBatch(new BatchSettings { ParallelLimit = 1 }, "1.sql", "2.sql"); + Script("1.sql", 5, rows: 5, messages: "Changed database context to 'Sales'."); + Script("2.sql", 2, ScriptStatus.Failed, error: "line 3 (part 1): boom"); + + list.Run(b.Id); + await Until(b.Id, r => r.Scripts[0].Status == ScriptStatus.Running); + time.Advance(TimeSpan.FromSeconds(5)); + await Until(b.Id, r => r.Scripts[1].Status == ScriptStatus.Running); + time.Advance(TimeSpan.FromSeconds(2)); + await Until(b.Id, Ended); + + Assert.Equal( + """ + 2026-10-09T14:00:00+02:00 1.sql #1: Started + 2026-10-09T14:00:00+02:00 1.sql #1: Changed database context to 'Sales'. + 2026-10-09T14:00:05+02:00 1.sql #1: Succeeded, 5 rows + 2026-10-09T14:00:05+02:00 2.sql #1: Started + 2026-10-09T14:00:07+02:00 2.sql #1: Error: line 3 (part 1): boom + 2026-10-09T14:00:07+02:00 2.sql #1: Failed: line 3 (part 1): boom + 2026-10-09T14:00:07+02:00 Run ended: 2 scripts, 1 succeeded, 1 failed, 0 cancelled. + 2026-10-09T14:00:07+02:00 1.sql: Succeeded, attempts: 1, rows: 5 + 2026-10-09T14:00:07+02:00 2.sql: Failed, attempts: 1, error: line 3 (part 1): boom + + """.ReplaceLineEndings(), + File.ReadAllText(Path.Combine(appData, "logs", "Untitled 1_20261009-140000", "run.log"))); + } + + [Fact] + public async Task Summary_csv_is_written_again_at_each_status_change_with_Running_and_Queued_rows() + { + var b = NewBatch(new BatchSettings { ParallelLimit = 1 }, "1.sql", "2.sql"); + Script("1.sql", 5); + var csv = Path.Combine(appData, "logs", "Untitled 1_20261009-140000", "summary.csv"); + + list.Run(b.Id); + await Until(b.Id, r => r.Scripts[0].Status == ScriptStatus.Running); + Assert.Equal( + """ + script,attempt,status,start,end,duration,rows,error,server,database + 1.sql,1,Running,2026-10-09T14:00:00+02:00,,,,,, + 2.sql,0,Queued,,,,,,, + + """.ReplaceLineEndings("\r\n"), + File.ReadAllText(csv)); + Assert.False(File.Exists(csv + ".tmp")); + + time.Advance(TimeSpan.FromSeconds(5)); + await Until(b.Id, Ended); + Assert.Contains("2.sql,1,Succeeded,2026-10-09T14:00:05+02:00,2026-10-09T14:00:05+02:00,0.000,", File.ReadAllText(csv)); + } + + [Fact] + public async Task A_second_run_in_the_same_second_gets_the_suffix_2_then_3() + { + var b = NewBatch(new BatchSettings(), "1.sql"); + for (var n = 0; n < 3; n++) + { + list.Run(b.Id); + await Until(b.Id, Ended); + } + + Assert.Equal(["Untitled 1_20261009-140000", "Untitled 1_20261009-140000-2", "Untitled 1_20261009-140000-3"], + Directory.GetDirectories(Path.Combine(appData, "logs")).Select(Path.GetFileName).Order(StringComparer.Ordinal)); + } + + [Fact] + public async Task A_saved_batch_logs_next_to_its_file_and_a_save_after_a_run_leaves_that_log_in_app_data() + { + var b = NewBatch(new BatchSettings(), "1.sql"); + list.Run(b.Id); + await Until(b.Id, Ended); + + list.Save(b.Id, Path.Combine(root, "deploy"), overwrite: false); + time.Advance(TimeSpan.FromSeconds(61)); + list.Run(b.Id); + await Until(b.Id, Ended); + + Assert.True(File.Exists(Path.Combine(appData, "logs", "Untitled 1_20261009-140000", "summary.csv"))); + Assert.True(File.Exists(Path.Combine(root, "logs", "deploy_20261009-140101", "summary.csv"))); + Assert.True(File.Exists(Path.Combine(root, "logs", "deploy_20261009-140101", "run.log"))); + } + + [Fact] + public async Task A_failed_rename_warns_in_run_log_and_on_the_page_and_the_next_status_change_tries_again() + { + var b = NewBatch(new BatchSettings { ParallelLimit = 1 }, "1.sql", "2.sql"); + Script("1.sql", 5); Script("2.sql", 5); + var folder = Path.Combine(appData, "logs", "Untitled 1_20261009-140000"); + var csv = Path.Combine(folder, "summary.csv"); + + using var page = list.Subscribe(); + list.Run(b.Id); + await Until(b.Id, r => r.Scripts[0].Status == ScriptStatus.Running); + File.Delete(csv); + Directory.CreateDirectory(csv); // the rename cannot replace a folder: the same as a file that Excel locks + time.Advance(TimeSpan.FromSeconds(5)); + var run = await Until(b.Id, r => r.Scripts[1].Status == ScriptStatus.Running); + + Assert.Contains("Warning: cannot write summary.csv", File.ReadAllText(Path.Combine(folder, "run.log"))); + Assert.Equal(ScriptStatus.Succeeded, run.Scripts[0].Status); // the run continues + List changes = []; + while (page.Changes.TryRead(out var c)) changes.Add(c); + var warning = Assert.Single(changes, c => c.Kind == "warning"); + Assert.Contains($"{{ id = {b.Id}, text = Warning: cannot write summary.csv", warning.Data.ToString()); + + Directory.Delete(csv); + time.Advance(TimeSpan.FromSeconds(5)); + await Until(b.Id, Ended); + Assert.Contains("2.sql,1,Succeeded", File.ReadAllText(csv)); + } +} From 156b7cdb594d45449bdeb8814a57aab65605fc89 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:13:34 +0200 Subject: [PATCH 47/96] feat(gui): log unsaved batches in the app data folder and show log warnings --- src/SqlRunboard.Gui/GuiHost.cs | 2 +- src/SqlRunboard.Gui/wwwroot/index.html | 1 + tests/SqlRunboard.Gui.Tests/RunLogTests.cs | 29 ++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 tests/SqlRunboard.Gui.Tests/RunLogTests.cs diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 7cbe75c..8253514 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -58,7 +58,7 @@ public static async Task StartAsync(GuiHostOptions options) builder.Services.AddSingleton(options); builder.Services.AddSingleton(options.Executor); builder.Services.AddSingleton(options.Time); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(new BatchList(options.Executor, options.Time, options.AppDataFolder)); builder.Services.ConfigureHttpJsonOptions(j => j.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); var app = builder.Build(); diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index f5d8b4c..de9cdf2 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -251,6 +251,7 @@ }); es.addEventListener('batch', e => { upsert(JSON.parse(e.data)); render(); }); es.addEventListener('remove', e => dropBatch(JSON.parse(e.data).id)); + es.addEventListener('warning', e => { const w = JSON.parse(e.data); toast(`${app.batches.find(b => b.id === w.id)?.name ?? 'Batch'}: ${w.text}`); }); es.onerror = () => { delete document.documentElement.dataset.connected; }; } diff --git a/tests/SqlRunboard.Gui.Tests/RunLogTests.cs b/tests/SqlRunboard.Gui.Tests/RunLogTests.cs new file mode 100644 index 0000000..3043c77 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/RunLogTests.cs @@ -0,0 +1,29 @@ +using Microsoft.Playwright; +using SqlRunboard.Core; + +public class RunLogTests : AppPageTest +{ + readonly string scripts = Directory.CreateTempSubdirectory("sqlrunboard-scripts-").FullName; + + [Fact] + public async Task A_run_of_an_unsaved_batch_logs_in_app_data_and_a_failed_summary_write_shows_a_warning() + { + File.WriteAllText(Path.Combine(scripts, "a.sql"), "select 1"); + File.WriteAllText(Path.Combine(scripts, "b.sql"), "select 1"); + App.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(5) }; + var b = App.Host.Batches.NewBatch(); + App.Host.Batches.UpdateSettings(b.Id, new BatchSettings { Folder = scripts, ParallelLimit = 1 }); + var csv = Path.Combine(App.AppData, "logs", $"{b.Name}_20261009-140000", "summary.csv"); + + await Page.GetByRole(AriaRole.Navigation, new() { Name = "Batches" }).Locator($"[role=option]:has-text('{b.Name}')").ClickAsync(); + await Page.Locator("#hdr").GetByRole(AriaRole.Button, new() { Name = "Run", Exact = true }).ClickAsync(); + await Expect(Page.Locator("#panel").GetByRole(AriaRole.Region, new() { Name = "Running", Exact = true })).ToContainTextAsync("a.sql"); + Assert.Contains("a.sql,1,Running", File.ReadAllText(csv)); + + File.Delete(csv); + Directory.CreateDirectory(csv); // the rename fails, as for a file that Excel locks + App.Time.Advance(TimeSpan.FromSeconds(5)); + + await Expect(Page.Locator("#toast")).ToContainTextAsync($"{b.Name}: Warning: cannot write summary.csv"); + } +} From cee9d62988773a242f2e876895efe4ed40eb4ed5 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:14:25 +0200 Subject: [PATCH 48/96] chore(ci): run the integration suite on Linux with a SQL Server service container --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98b1ba0..e362424 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,3 +28,30 @@ jobs: - run: dotnet test --no-build env: BROWSER: ${{ matrix.browser }} + + # The integration suite against a real SQL Server, on Linux only (docs/SPEC.md "Tests"). + # The SA password is a throwaway for this run's container, which only this job can reach. + integration: + runs-on: ubuntu-latest + services: + sql: + image: mcr.microsoft.com/mssql/server:2022-latest + env: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: Rb-${{ github.run_id }}-${{ github.run_attempt }}-Sql9 + ports: + - 1433:1433 + options: >- + --health-cmd "/opt/mssql-tools18/bin/sqlcmd -C -b -S localhost -U sa -P $MSSQL_SA_PASSWORD -Q 'SELECT 1'" + --health-interval 10s --health-timeout 5s --health-retries 20 + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + - run: dotnet test --project tests/SqlRunboard.Integration.Tests + env: + SQLRUNBOARD_TEST_SQL: Server=localhost,1433;User ID=sa;Password=Rb-${{ github.run_id }}-${{ github.run_attempt }}-Sql9;TrustServerCertificate=true From 7401063ac87cc194c8150bb789a9f59c757583ba Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:14:35 +0200 Subject: [PATCH 49/96] test(core): reruns write to the log folder of the current run --- tests/SqlRunboard.Core.Tests/RunLogTests.cs | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/SqlRunboard.Core.Tests/RunLogTests.cs b/tests/SqlRunboard.Core.Tests/RunLogTests.cs index 01ac2d0..ff55804 100644 --- a/tests/SqlRunboard.Core.Tests/RunLogTests.cs +++ b/tests/SqlRunboard.Core.Tests/RunLogTests.cs @@ -133,6 +133,37 @@ public async Task Summary_csv_is_written_again_at_each_status_change_with_Runnin Assert.Contains("2.sql,1,Succeeded,2026-10-09T14:00:05+02:00,2026-10-09T14:00:05+02:00,0.000,", File.ReadAllText(csv)); } + [Fact] + public async Task A_rerun_adds_its_attempt_to_the_same_folder_and_run_log_gets_a_second_summary() + { + var b = NewBatch(new BatchSettings(), "1.sql"); + Script("1.sql", 2, ScriptStatus.Failed, error: "boom"); + list.Run(b.Id); + await Until(b.Id, r => r.Scripts[0].Status == ScriptStatus.Running); + time.Advance(TimeSpan.FromSeconds(2)); + await Until(b.Id, Ended); + + Script("1.sql", 3); + time.Advance(TimeSpan.FromSeconds(60)); + list.Rerun(b.Id, "1.sql"); + await Until(b.Id, r => r.Scripts[0].Status == ScriptStatus.Running); + time.Advance(TimeSpan.FromSeconds(3)); + await Until(b.Id, Ended); + + var folder = Assert.Single(Directory.GetDirectories(Path.Combine(appData, "logs"))); + Assert.EndsWith( + """ + 1.sql,1,Failed,2026-10-09T14:00:00+02:00,2026-10-09T14:00:02+02:00,2.000,,boom,, + 1.sql,2,Succeeded,2026-10-09T14:01:02+02:00,2026-10-09T14:01:05+02:00,3.000,,,, + + """.ReplaceLineEndings("\r\n"), + File.ReadAllText(Path.Combine(folder, "summary.csv"))); + var log = File.ReadAllText(Path.Combine(folder, "run.log")); + Assert.Contains("2026-10-09T14:01:02+02:00 1.sql #2: Started", log); + Assert.Equal(2, log.Split("Run ended:").Length - 1); + Assert.EndsWith("1.sql: Succeeded, attempts: 2" + Environment.NewLine, log); + } + [Fact] public async Task A_second_run_in_the_same_second_gets_the_suffix_2_then_3() { From fb0ff485c0dbaa16ea062988b5f4469a91a57e9d Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:17:27 +0200 Subject: [PATCH 50/96] fix(gui): hold page renders while a pointer button is down so live runs do not lose clicks --- src/SqlRunboard.Gui/wwwroot/index.html | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 417fb7a..60661a2 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -536,7 +536,14 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} return `${scope.id ? '#' + scope.id : '.topbar'} [${a}="${CSS.escape(el.getAttribute(a))}"]`; } const refocus = sel => sel && document.querySelector(sel)?.focus({ preventScroll: true }); +// A rebuild between pointerdown and pointerup swaps the element under the pointer, and the browser then sends no click +// (live runs rebuild every second). So hold the renders while a button is down, and render once when it comes up. +// ponytail: a pointerdown with no pointerup (some scrollbars) holds the renders until the next click. +let held = false; +addEventListener('pointerdown', () => { held = true; }, true); +for (const t of ['pointerup', 'pointercancel']) addEventListener(t, () => { held = false; setTimeout(render); }, true); function render(o = {}) { + if (held) return; const b = cur(), ae = document.activeElement, k = focusSel(ae); const panelEl = document.getElementById('panel'), drawerEl = document.getElementById('drawer'); const msgsEl = drawerEl.querySelector('.msgs'), msgsBottom = msgsEl && msgsEl.scrollTop + msgsEl.clientHeight >= msgsEl.scrollHeight - 4, msgsTop = msgsEl?.scrollTop; From 67e40a3f504de9162a93f66b099ddfeff89bf284 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:17:27 +0200 Subject: [PATCH 51/96] test(gui): open the Run tab by hand in the run view tests --- tests/SqlRunboard.Gui.Tests/RunViewTests.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/SqlRunboard.Gui.Tests/RunViewTests.cs b/tests/SqlRunboard.Gui.Tests/RunViewTests.cs index 9c96a67..01a3b57 100644 --- a/tests/SqlRunboard.Gui.Tests/RunViewTests.cs +++ b/tests/SqlRunboard.Gui.Tests/RunViewTests.cs @@ -11,6 +11,8 @@ public class RunViewTests : AppPageTest ILocator Panel => Page.Locator("#panel"); ILocator View(string name) => Panel.GetByRole(AriaRole.Group, new() { Name = "View" }).GetByRole(AriaRole.Button, new() { Name = name }); ILocator Side(string name) => Page.GetByRole(AriaRole.Navigation, new() { Name = "Batches" }).Locator($"[role=option]:has-text('{name}')"); + // The batch events can come after the click on the side list, so open the Run tab by hand. + async Task OpenRun(string name) { await Side(name).ClickAsync(); await Page.GetByRole(AriaRole.Tab, new() { Name = "Run" }).ClickAsync(); } ILocator Rows => Panel.Locator("table tbody tr"); Batch RunningBatch() @@ -27,7 +29,7 @@ Batch RunningBatch() public async Task The_table_shows_the_run_in_queue_order() { var b = RunningBatch(); - await Side(b.Name).ClickAsync(); + await OpenRun(b.Name); await View("Table").ClickAsync(); await Expect(Panel.Locator("table thead th")).ToHaveTextAsync(["#", "Status", "Script", "Start", "Elapsed", "Attempts", "Rows"]); @@ -43,7 +45,7 @@ public async Task The_table_shows_the_run_in_queue_order() public async Task Board_and_table_use_the_same_selection() { var b = RunningBatch(); - await Side(b.Name).ClickAsync(); + await OpenRun(b.Name); await Panel.Locator(".card:has-text('b.sql')").ClickAsync(); await View("Table").ClickAsync(); @@ -57,7 +59,7 @@ public async Task Board_and_table_use_the_same_selection() public async Task The_right_click_menu_works_on_a_table_row() { var b = RunningBatch(); - await Side(b.Name).ClickAsync(); + await OpenRun(b.Name); await View("Table").ClickAsync(); await Panel.Locator("tr:has-text('c.sql')").ClickAsync(new() { Button = MouseButton.Right }); @@ -71,7 +73,7 @@ public async Task The_right_click_menu_works_on_a_table_row() public async Task The_Run_tab_opens_the_last_view_also_after_a_restart() { var b = RunningBatch(); - await Side(b.Name).ClickAsync(); + await OpenRun(b.Name); await View("Table").ClickAsync(); await Expect(View("Table")).ToHaveAttributeAsync("aria-pressed", "true"); @@ -87,6 +89,7 @@ public async Task The_Run_tab_opens_the_last_view_also_after_a_restart() var page = await context.NewPageAsync(); await OpenAsync(page, again.StartUrl); await page.Locator($"[role=option]:has-text('{b2.Name}')").ClickAsync(); + await page.GetByRole(AriaRole.Tab, new() { Name = "Run" }).ClickAsync(); await Expect(page.Locator("#panel table")).ToBeVisibleAsync(); await Expect(page.GetByRole(AriaRole.Button, new() { Name = "Table" })).ToHaveAttributeAsync("aria-pressed", "true"); From 09689140caf3e4421043faf1610447db9625ba29 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:17:42 +0200 Subject: [PATCH 52/96] feat(gui): check the logon before a rerun after the run ended --- src/SqlRunboard.Gui/wwwroot/index.html | 6 ++++-- tests/SqlRunboard.Gui.Tests/LogonTests.cs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index a468ab0..8d7fbef 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -302,8 +302,10 @@ } const cancel = (b, path) => sendEdit(`/api/batches/${b.id}/cancel`, { path }); const cancelAll = b => sendEdit(`/api/batches/${b.id}/cancel-all`, {}); -const rerun = (b, path) => sendEdit(`/api/batches/${b.id}/rerun`, { path }); -const rerunFailed = b => sendEdit(`/api/batches/${b.id}/rerun-failed`, {}); +// A rerun after the run ended checks the logon first, as Run does. A rerun during the run uses the kept password. +const afterLogon = (b, go) => b.run?.ended ? logon(b, go) : go(); +const rerun = (b, path) => afterLogon(b, () => sendEdit(`/api/batches/${b.id}/rerun`, { path })); +const rerunFailed = b => afterLogon(b, () => sendEdit(`/api/batches/${b.id}/rerun-failed`, {})); function setInclude(b, s, include) { s.include = include; render(); sendEdit(`/api/batches/${b.id}/include`, { path: s.path, include }); } // The folder, Open and Save as dialogs: the server lists the folders (and the batch files). kind: folder, open or saveAs. async function browse(b, path, kind = 'folder') { diff --git a/tests/SqlRunboard.Gui.Tests/LogonTests.cs b/tests/SqlRunboard.Gui.Tests/LogonTests.cs index 5e76f49..c5f053e 100644 --- a/tests/SqlRunboard.Gui.Tests/LogonTests.cs +++ b/tests/SqlRunboard.Gui.Tests/LogonTests.cs @@ -84,6 +84,22 @@ public async Task A_target_change_asks_for_the_password_again() await Expect(Dialog).ToContainTextAsync("Other"); } + [Fact] + public async Task A_script_that_fails_its_logon_asks_for_the_password_at_the_rerun() + { + await OpenBatchAsync(SqlLogon); + App.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(10), Status = ScriptStatus.Failed, Error = "Login failed for user 'deploy'.", LogonFailed = true }; + await RunButton.ClickAsync(); + await SignInAsync("secret"); + await RunAndWaitAsync(1); + + await Header.GetByRole(AriaRole.Button, new() { Name = "Rerun all failed" }).ClickAsync(); + await SignInAsync("again"); + + await Expect(Panel.GetByRole(AriaRole.Region, new() { Name = "Running", Exact = true }).Locator(".card")).ToHaveCountAsync(1); + Assert.Equal(["secret", "again"], App.Executor.Calls.Select(c => c.Connection.Password)); + } + [Fact] public async Task Test_connection_shows_the_server_name_and_the_database() { From 193cfd77eb5c7683031dc4936e074a95cbb03f26 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:55:09 +0200 Subject: [PATCH 53/96] feat(core): stop all batches and wait until the logs are written --- src/SqlRunboard.Core/BatchList.cs | 17 +++++++++++++++-- tests/SqlRunboard.Core.Tests/RunLogTests.cs | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index 8a2fd40..f975b12 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -18,6 +18,7 @@ public sealed class BatchList(ISqlExecutor executor, TimeProvider time, string? readonly Dictionary runs = []; readonly List> subscribers = []; readonly Dictionary loaded = []; // batch id -> the file text that the app last read or wrote + readonly HashSet finishing = []; // one per Running script: ends after Finish wrote its state and log int lastId, lastUntitled; public Batch NewBatch() @@ -223,6 +224,15 @@ public bool Remove(int id) return batch; } + /// Quit and Ctrl+C: cancels all scripts of all batches. The task ends when the Running scripts stopped and their logs are written. + public Task StopAsync() + { + int[] ids; + lock (gate) ids = [.. runs.Keys]; + foreach (var id in ids) CancelAll(id); + lock (gate) return Task.WhenAll(finishing); + } + /// /// Reruns one completed script of the current run. It goes to the bottom of the queue and reads its file again. /// Returns the batch, or null if no batch has this id. @@ -303,8 +313,11 @@ void Pump(int id, ActiveRun run) var execution = new SqlExecution(path, parts, new SqlConnectionSettings(run.Settings.Target, Password: null)); // The continuation runs on the thread pool, never inline here under the lock. var cts = run.Tokens[path] = run.Settings.TimeoutSeconds is int s ? new CancellationTokenSource(TimeSpan.FromSeconds(s), time) : new CancellationTokenSource(); - _ = executor.ExecuteAsync(execution, m => run.Log?.Append($"{attempt}: {(m.Kind == SqlMessageKind.Error ? "Error: " : "")}{m.Text}"), cts.Token) - .ContinueWith(t => Finish(id, run, path, start, t), TaskScheduler.Default); + Task? done = null; + // The continuation takes the lock, so it cannot remove 'done' before this method adds it. + done = executor.ExecuteAsync(execution, m => run.Log?.Append($"{attempt}: {(m.Kind == SqlMessageKind.Error ? "Error: " : "")}{m.Text}"), cts.Token) + .ContinueWith(t => { Finish(id, run, path, start, t); lock (gate) finishing.Remove(done!); }, TaskScheduler.Default); + finishing.Add(done); } run.Log?.Update(run.Scripts.Values, run.Settings.Target); // also for a run that is no longer current: its late ends go to its log var i = batches.FindIndex(b => b.Id == id); diff --git a/tests/SqlRunboard.Core.Tests/RunLogTests.cs b/tests/SqlRunboard.Core.Tests/RunLogTests.cs index ff55804..3d386f7 100644 --- a/tests/SqlRunboard.Core.Tests/RunLogTests.cs +++ b/tests/SqlRunboard.Core.Tests/RunLogTests.cs @@ -223,4 +223,21 @@ public async Task A_failed_rename_warns_in_run_log_and_on_the_page_and_the_next_ await Until(b.Id, Ended); Assert.Contains("2.sql,1,Succeeded", File.ReadAllText(csv)); } + + [Fact] + public async Task Stop_cancels_all_scripts_of_all_batches_and_ends_when_the_logs_are_written() + { + var a = NewBatch(new BatchSettings { ParallelLimit = 1 }, "1.sql", "2.sql"); + Script("1.sql", 60); Script("2.sql", 60); + list.Run(a.Id); + await Until(a.Id, r => r.Scripts[0].Status == ScriptStatus.Running); + + await list.StopAsync().WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + var folder = Path.Combine(appData, "logs", "Untitled 1_20261009-140000"); + var csv = File.ReadAllText(Path.Combine(folder, "summary.csv")); + Assert.Contains("1.sql,1,Cancelled", csv); + Assert.Contains("2.sql,0,Cancelled", csv); + Assert.Contains("Run ended: 2 scripts, 0 succeeded, 0 failed, 2 cancelled.", File.ReadAllText(Path.Combine(folder, "run.log"))); + } } From f9ce1715b4dc5a1a5370eddddca77ea7bc4d7721 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:00:11 +0200 Subject: [PATCH 54/96] feat(gui): add quit route; stopping cancels all scripts, writes logs and removes the lock file --- src/SqlRunboard.Gui/GuiHost.cs | 13 +++++++++- tests/SqlRunboard.Gui.Tests/QuitTests.cs | 31 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/SqlRunboard.Gui.Tests/QuitTests.cs diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 5e4dcd7..f9c2cf3 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -90,6 +90,8 @@ public static async Task StartAsync(GuiHostOptions options) app.MapPost("/api/batches/{id:int}/save", (int id, SaveRequest r, BatchList list) => FileResult(() => list.Save(id, r.Path, r.Overwrite))); app.MapPost("/api/batches/{id:int}/reload", (int id, BatchList list) => FileResult(() => list.Reload(id))); app.MapPost("/api/batches/{id:int}/remove", (int id, BatchList list) => list.Remove(id) ? Results.Ok(new { }) : Results.NotFound()); + // Quit: the page asked to save and confirmed. The stop itself runs in the ApplicationStopping handler below. + app.MapPost("/api/quit", (IHostApplicationLifetime lifetime) => { lifetime.StopApplication(); return Results.Ok(new { }); }); app.MapPost("/api/app-data/open", (GuiHostOptions o) => { // UseShellExecute on a folder opens the file manager: Explorer, Finder or xdg-open. @@ -108,8 +110,17 @@ public static async Task StartAsync(GuiHostOptions options) return Results.Ok(new { path, parent = Path.GetDirectoryName(path), folders, files }); }); + var lockPath = Path.Combine(options.AppDataFolder, LockFileName); + // Quit, Ctrl+C (ConsoleLifetime), SIGTERM and DisposeAsync all come here: cancel all scripts, write the logs, remove the lock file. + app.Lifetime.ApplicationStopping.Register(() => + { + // ponytail: a script that does not stop in 10 s keeps no final log line; raise the limit if a real cancel takes longer. + app.Services.GetRequiredService().StopAsync().Wait(TimeSpan.FromSeconds(10)); + File.Delete(lockPath); + }); + await app.StartAsync(); - var host = new GuiHost(app, new Uri(app.Urls.First()), token, Path.Combine(options.AppDataFolder, LockFileName)); + var host = new GuiHost(app, new Uri(app.Urls.First()), token, lockPath); host.WriteLock(); return host; } diff --git a/tests/SqlRunboard.Gui.Tests/QuitTests.cs b/tests/SqlRunboard.Gui.Tests/QuitTests.cs new file mode 100644 index 0000000..2c6a3ea --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/QuitTests.cs @@ -0,0 +1,31 @@ +using System.Net; +using System.Net.Http.Json; +using SqlRunboard.Core; +using SqlRunboard.Gui; + +/// Quit stops the app: it cancels all scripts, writes the logs, removes the lock file and stops the server. +public class QuitTests +{ + static readonly CancellationToken Ct = TestContext.Current.CancellationToken; + + [Fact] + public async Task Quit_cancels_all_scripts_removes_the_lock_file_and_stops_the_server() + { + await using var app = await TestApp.StartAsync(); + var scripts = Directory.CreateTempSubdirectory("sqlrunboard-scripts-").FullName; + File.WriteAllText(Path.Combine(scripts, "a.sql"), "select 1"); + app.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromMinutes(1) }; + var b = app.Host.Batches.NewBatch(); + app.Host.Batches.UpdateSettings(b.Id, new BatchSettings { Folder = scripts }); + app.Host.Batches.Run(b.Id); + + var res = await app.Client().PostAsJsonAsync("/api/quit", new { }, Ct); + + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + var csv = Path.Combine(app.AppData, "logs", $"{b.Name}_20261009-140000", "summary.csv"); + Assert.Contains("a.sql,1,Cancelled", File.ReadAllText(csv)); + Assert.False(File.Exists(Path.Combine(app.AppData, GuiHost.LockFileName))); + await app.Host.WaitForShutdownAsync().WaitAsync(TimeSpan.FromSeconds(10), Ct); + Directory.Delete(scripts, recursive: true); + } +} From 311a790594856402f79e949335e375e9e9907156 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:35:24 +0200 Subject: [PATCH 55/96] feat(gui): add Quit button that asks to save unsaved batches and confirms during a run --- src/SqlRunboard.Gui/wwwroot/index.html | 56 +++++++++++---- tests/SqlRunboard.Gui.Tests/QuitTests.cs | 92 ++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 13 deletions(-) diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 6cb00fd..aba53c7 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -179,6 +179,7 @@ + @@ -240,8 +241,9 @@ return res.json(); } // SSE: the full state first ("snapshot"), then one event per change. EventSource sends the auth cookie and reconnects by itself. +let es; function connect() { - const es = new EventSource('/events'); + es = new EventSource('/events'); es.addEventListener('snapshot', e => { const ids = JSON.parse(e.data).batches.map(sb => upsert(sb).id); app.batches = app.batches.filter(b => ids.includes(b.id)); @@ -291,21 +293,22 @@ const rerunFailed = b => sendEdit(`/api/batches/${b.id}/rerun-failed`, {}); function setInclude(b, s, include) { s.include = include; render(); sendEdit(`/api/batches/${b.id}/include`, { path: s.path, include }); } // The folder, Open and Save as dialogs: the server lists the folders (and the batch files). kind: folder, open or saveAs. -async function browse(b, path, kind = 'folder') { +async function browse(b, path, kind = 'folder', resolve) { const name = app.dialog?.kind === kind ? document.getElementById('dlg-in')?.value : undefined; // keep the typed file name - try { openDialog({ kind, b, name, ...await post('/api/folders', { path, files: kind === 'folder' ? null : '*' + EXT }) }); } - catch { if (path) browse(b, null, kind); else toast('The folder list is not available.'); } + try { openDialog({ kind, b, name, resolve, ...await post('/api/folders', { path, files: kind === 'folder' ? null : '*' + EXT }) }); } + catch { if (path) browse(b, null, kind, resolve); else { toast('The folder list is not available.'); resolve?.(false); } } } // Batch files. The server reads and writes them; the page sends full paths. const EXT = '.sqlrunboard.json'; const isAbs = p => /^([a-zA-Z]:[\\/]|[\\/])/.test(p ?? ''); const join = (dir, p) => isAbs(p) ? p : dir.replace(/[\\/]$/, '') + (dir.includes('\\') ? '\\' : '/') + p; -function saveBatch(b, path = null, overwrite = false) { // Ctrl+S; an unsaved batch does Save as +function saveBatch(b, path = null, overwrite = false) { // Ctrl+S; an unsaved batch does Save as. Resolves true when the file is written. if (!b.file && !path) return saveAs(b); - queued(`/api/batches/${b.id}/save`, { path, overwrite }).then(sb => { upsert(sb); render(); announce(`Saved ${sb.name}.`); }, - e => e.status === 409 ? openDialog({ kind: 'changed', b, path }) : toast(e.message)); + return queued(`/api/batches/${b.id}/save`, { path, overwrite }).then(sb => { upsert(sb); render(); announce(`Saved ${sb.name}.`); return true; }, + e => { if (e.status === 409) openDialog({ kind: 'changed', b, path }); else toast(e.message); return false; }); } -const saveAs = b => b && !b.missing && browse(b, b.file ?? (isAbs(b.settings.folder) ? b.settings.folder : null), 'saveAs'); +// resolve (optional) gets the result of the save, or null if you cancel the dialog. +const saveAs = (b, resolve) => b && !b.missing && browse(b, b.file ?? (isAbs(b.settings.folder) ? b.settings.folder : null), 'saveAs', resolve); const openBatch = () => browse(cur(), cur()?.file ?? null, 'open'); async function openFile(path) { // the server gives the existing entry if the file is already in the list try { const b = upsert(await post('/api/batches/open', { path })); app.cur = b.id; closeDialog(); } catch (e) { toast(e.message); } @@ -604,8 +607,12 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} function toast(text) { const t = document.getElementById('toast'); t.textContent = text; clearTimeout(toastTimer); toastTimer = setTimeout(() => t.textContent = '', 4000); } // ---------- dialogs and menu ---------- -function openDialog(d) { app.dialog = { ...d, back: app.dialog?.back ?? focusSel(document.activeElement) }; renderDialog(); } -function closeDialog() { const back = app.dialog?.back; app.dialog = null; renderDialog(); render(); refocus(back); } +// d.resolve: a dialog that opens again with the same kind (browse a folder) keeps the resolve of the dialog before it. +function openDialog(d) { app.dialog = { ...d, back: app.dialog?.back ?? focusSel(document.activeElement), resolve: d.resolve ?? (app.dialog?.kind === d.kind ? app.dialog.resolve : undefined) }; renderDialog(); } +function closeDialog() { const back = app.dialog?.back, resolve = app.dialog?.resolve; app.dialog = null; renderDialog(); render(); refocus(back); resolve?.(null); } +// A question: resolves with the data-dlg value of the button ('ok', 'discard'), or null for Cancel and Escape. +const ask = d => new Promise(resolve => openDialog({ ...d, ask: true, resolve })); +function answer(v) { const r = app.dialog.resolve; app.dialog.resolve = null; closeDialog(); r(v); } function renderDialog() { const d = app.dialog, el = document.getElementById('dialog'); if (!d) return el.innerHTML = ''; @@ -630,20 +637,25 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')}
${d.parent ? `` : ''}${d.folders.map(f => ``).join('')}
`, + quitSave: () => `

Save ${esc(d.b.name)}?

The batch has changes that are not saved. If you do not save them, the changes are lost.

+
`, + stopped: () => `

SQLRunboard stopped

You can close this window.

`, closeRun: () => `

Close SQLRunboard?

${esc(d.text)}

`, }[d.kind](); el.innerHTML = `
`; - (el.querySelector('#dlg-in') ?? el.querySelector('.primary')).focus(); + (el.querySelector('#dlg-in') ?? el.querySelector('.primary'))?.focus(); } const fileList = d => `

${esc(d.path)}

${d.parent ? `` : ''}${d.folders.map(f => ``).join('')} ${d.files.map(f => ``).join('')}
`; function dialogOk() { const d = app.dialog, v = document.getElementById('dlg-in')?.value ?? ''; + if (d.ask) return answer('ok'); + if (d.kind === 'stopped') return; if (d.kind === 'password') { if (!v) return; d.b.password = v; app.dialog = null; renderDialog(); logon(d.b, d.then); return; } if (d.kind === 'folder') { d.b.settings.folder = d.path; saveSettings(d.b); closeDialog(); return; } if (d.kind === 'confirm') { app.dialog = null; renderDialog(); d.then(); render(); return; } - if (d.kind === 'saveAs') { if (!v.trim()) return; closeDialog(); saveBatch(d.b, join(d.path, v.trim())); return; } + if (d.kind === 'saveAs') { if (!v.trim()) return; const r = d.resolve; d.resolve = null; closeDialog(); const saved = saveBatch(d.b, join(d.path, v.trim())); r?.(saved); return; } if (d.kind === 'open') { if (!v.trim()) return; const p = join(d.path, v.trim()); return p.toLowerCase().endsWith(EXT) ? openFile(p) : browse(d.b, p, 'open'); } if (d.kind === 'changed') { closeDialog(); saveBatch(d.b, d.path, true); return; } closeDialog(); @@ -676,6 +688,22 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} try { b = upsert(await post('/api/batches')); } catch (e) { return toast(e.message); } // the SSE event can come before or after this app.cur = b.id; b.tab = 'settings'; render(); } +// Quit: asks to save each unsaved batch, and asks for confirmation during a run. Then the server cancels all scripts, writes the logs and stops. +// The desktop shell (#22) calls quit() from the close event of the window. It resolves true when the app stopped. +async function quit() { + if (app.dialog) return false; + for (const b of app.batches.filter(x => x.dirty)) { + app.cur = b.id; render(); + const a = await ask({ kind: 'quitSave', b }); + if (!a || (a === 'ok' && !await (b.file ? saveBatch(b) : new Promise(r => saveAs(b, r))))) return false; + } + const n = app.batches.filter(b => b.run && !b.run.ended).length; + if (n && await ask({ kind: 'closeRun', text: `Scripts run in ${n} ${n === 1 ? 'batch' : 'batches'}. Quit cancels all scripts.` }) !== 'ok') return false; + try { await post('/api/quit'); } catch (e) { toast(e.message); return false; } + es.close(); delete document.documentElement.dataset.connected; + openDialog({ kind: 'stopped' }); + return true; +} function selectScript(b, path, openDrawer) { b.sel = path; if (openDrawer) b.drawer = true; render(); } function cmd(c, el) { const b = cur(); @@ -686,6 +714,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} case 'saveAs': return saveAs(b); case 'remove': return removeBatch(b); case 'appData': return post('/api/app-data/open').catch(e => toast(e.message)); + case 'quit': return quit(); case 'run': return runBatch(b); case 'cancelSignIn': b.signing = null; return render(); case 'cancelAll': cancelAll(b); return render(); @@ -705,7 +734,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} if (app.menu && !e.target.closest('#menu')) closeMenu(false); const t = e.target, b = cur(); const dlg = t.closest('[data-dlg]'); - if (dlg) { e.preventDefault(); const d = app.dialog; return dlg.dataset.dlg === 'ok' ? dialogOk() : (closeDialog(), dlg.dataset.dlg === 'reload' && reloadBatch(d.b)); } + if (dlg) { e.preventDefault(); const d = app.dialog; if (d.ask) return answer(dlg.dataset.dlg === 'cancel' ? null : dlg.dataset.dlg); return dlg.dataset.dlg === 'ok' ? dialogOk() : (closeDialog(), dlg.dataset.dlg === 'reload' && reloadBatch(d.b)); } const fo = t.closest('[data-folder]'); if (fo) return browse(app.dialog.b, fo.dataset.folder, app.dialog.kind); const pk = t.closest('[data-pick]'); if (pk) { if (app.dialog.kind === 'open') return openFile(pk.dataset.pick); document.getElementById('dlg-in').value = pk.textContent; return; } @@ -752,6 +781,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} document.addEventListener('keydown', e => { const b = cur(), mod = isMac ? e.metaKey : e.ctrlKey, t = e.target; if (app.dialog) { + if (app.dialog.kind === 'stopped') return; if (e.key === 'Escape') closeDialog(); else if (e.key === 'Enter' && !t.matches('button')) { e.preventDefault(); dialogOk(); } else if (e.key === 'Tab') { const f = [...document.querySelectorAll('#dialog input, #dialog button')]; const i = f.indexOf(t); if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus(); } else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus(); } } diff --git a/tests/SqlRunboard.Gui.Tests/QuitTests.cs b/tests/SqlRunboard.Gui.Tests/QuitTests.cs index 2c6a3ea..c06567f 100644 --- a/tests/SqlRunboard.Gui.Tests/QuitTests.cs +++ b/tests/SqlRunboard.Gui.Tests/QuitTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; +using Microsoft.Playwright; using SqlRunboard.Core; using SqlRunboard.Gui; @@ -29,3 +30,94 @@ public async Task Quit_cancels_all_scripts_removes_the_lock_file_and_stops_the_s Directory.Delete(scripts, recursive: true); } } + +/// The Quit button in the page: it asks to save each unsaved batch, and asks for confirmation during a run. +public class QuitPageTests : AppPageTest +{ + static readonly CancellationToken Ct = TestContext.Current.CancellationToken; + readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-quit-").FullName; + + ILocator Dialog => Page.GetByRole(AriaRole.Dialog); + Task ClickAsync(string name) => Dialog.GetByRole(AriaRole.Button, new() { Name = name, Exact = true }).ClickAsync(); + Task QuitAsync() => Page.GetByRole(AriaRole.Toolbar, new() { Name = "App" }).GetByRole(AriaRole.Button, new() { Name = "Quit" }).ClickAsync(); + string LockFile => Path.Combine(App.AppData, GuiHost.LockFileName); + + /// A saved batch with one script, so it is not dirty. + Batch SavedBatch(string name, string script) + { + File.WriteAllText(Path.Combine(root, script), "select 1"); + var b = App.Host.Batches.NewBatch(); + App.Host.Batches.UpdateSettings(b.Id, new BatchSettings { Folder = root, Filters = script }); + return App.Host.Batches.Save(b.Id, Path.Combine(root, name), overwrite: false)!; + } + + [Fact] + public async Task Quit_asks_to_save_each_unsaved_batch_then_stops_the_app() + { + var saved = SavedBatch("deploy", "a.sql"); + App.Host.Batches.SetInclude(saved.Id, "a.sql", false); // dirty + App.Host.Batches.NewBatch(); // Untitled 2: never saved + await OpenAsync(Page, App.Host.BaseUrl); + + await QuitAsync(); + await Expect(Dialog).ToContainTextAsync("Save deploy?"); + await ClickAsync("Save"); + await Expect(Dialog).ToContainTextAsync("Save Untitled 2?"); + await ClickAsync("Don't save"); + + await Expect(Dialog).ToContainTextAsync("SQLRunboard stopped"); + Assert.Contains("\"include\": false", File.ReadAllText(saved.File!)); + Assert.False(File.Exists(LockFile)); + await App.Host.WaitForShutdownAsync().WaitAsync(TimeSpan.FromSeconds(10), Ct); + } + + [Fact] + public async Task Save_on_a_never_saved_batch_opens_Save_as_and_then_quits() + { + App.Host.Batches.NewBatch(); + await OpenAsync(Page, App.Host.BaseUrl); + + await QuitAsync(); + await ClickAsync("Save"); + await Dialog.GetByLabel("File name").FillAsync(Path.Combine(root, "nightly")); + await ClickAsync("Save"); + + await Expect(Dialog).ToContainTextAsync("SQLRunboard stopped"); + Assert.True(File.Exists(Path.Combine(root, "nightly.sqlrunboard.json"))); + } + + [Fact] + public async Task Cancel_on_a_save_question_keeps_the_app_running() + { + App.Host.Batches.NewBatch(); + await OpenAsync(Page, App.Host.BaseUrl); + + await QuitAsync(); + await ClickAsync("Cancel"); + + await Expect(Dialog).ToHaveCountAsync(0); + Assert.True(File.Exists(LockFile)); + } + + [Fact] + public async Task During_a_run_Quit_asks_for_confirmation_and_then_cancels_all_scripts() + { + var b = SavedBatch("load", "a.sql"); + App.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromMinutes(1) }; + App.Host.Batches.Run(b.Id); + await OpenAsync(Page, App.Host.BaseUrl); + + await QuitAsync(); + await Expect(Dialog).ToContainTextAsync("Close SQLRunboard?"); + await ClickAsync("Keep open"); + await Expect(Dialog).ToHaveCountAsync(0); + Assert.True(File.Exists(LockFile)); + + await QuitAsync(); + await ClickAsync("Cancel all and close"); + + await Expect(Dialog).ToContainTextAsync("SQLRunboard stopped"); + var csv = Directory.GetFiles(Path.Combine(root, "logs"), "summary.csv", SearchOption.AllDirectories).Single(); + Assert.Contains("a.sql,1,Cancelled", File.ReadAllText(csv)); + } +} From f41ca961560a21453c2b98e9ceb75a3ab8ded62d Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:35:54 +0200 Subject: [PATCH 56/96] test(cli): Ctrl+C stops the server and removes the lock file --- .../SqlRunboard.Gui.Tests/ExecutableTests.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs b/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs index 39b4b2b..b1d909d 100644 --- a/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs +++ b/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs @@ -44,6 +44,35 @@ public async Task Starts_the_server_in_the_given_app_data_folder_without_a_brows } } + [Fact] + public async Task Ctrl_C_stops_the_server_and_removes_the_lock_file() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "No SIGINT for a child process on Windows. Quit and Ctrl+C share the ApplicationStopping handler (QuitTests)."); + var appData = Directory.CreateTempSubdirectory("sqlrunboard-exe-").FullName; + var psi = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") + { + ArgumentList = { Path.Combine(AppContext.BaseDirectory, "sqlrunboard.dll"), "--app-data", appData, "--no-browser" }, + RedirectStandardOutput = true, + }; + using var exe = Process.Start(psi)!; + try + { + await exe.StandardOutput.ReadLineAsync(Ct).AsTask().WaitAsync(TimeSpan.FromSeconds(30), Ct); + Assert.True(File.Exists(Path.Combine(appData, "sqlrunboard.lock"))); + + using (var kill = Process.Start("kill", ["-INT", exe.Id.ToString()])) await kill.WaitForExitAsync(Ct); // Ctrl+C + await exe.WaitForExitAsync(Ct).WaitAsync(TimeSpan.FromSeconds(30), Ct); + + Assert.Equal(0, exe.ExitCode); + Assert.False(File.Exists(Path.Combine(appData, "sqlrunboard.lock"))); + } + finally + { + if (!exe.HasExited) exe.Kill(entireProcessTree: true); + Directory.Delete(appData, recursive: true); + } + } + [Fact] public async Task A_second_start_opens_the_running_server_and_stops() { From eaf785ddf69947e5c35199897f638045fbb83e1b Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:36:38 +0200 Subject: [PATCH 57/96] feat(desktop): close the window when the server stops --- src/sqlrunboard-desktop/Program.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sqlrunboard-desktop/Program.cs b/src/sqlrunboard-desktop/Program.cs index 4cbd80b..8e998b0 100644 --- a/src/sqlrunboard-desktop/Program.cs +++ b/src/sqlrunboard-desktop/Program.cs @@ -3,6 +3,7 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; +using Avalonia.Threading; using SqlRunboard.Core; using SqlRunboard.Gui; @@ -40,6 +41,8 @@ Template = new FuncControlTemplate((w, _) => new ContentPresenter { [!ContentPresenter.ContentProperty] = w[!ContentControl.ContentProperty] }), Content = new NativeWebView { Source = startUrl }, }; +// Quit in the page, Ctrl+C and SIGTERM stop the server: then the window closes too. +host?.WaitForShutdownAsync().ContinueWith(_ => Dispatcher.UIThread.Post(() => lifetime.Shutdown()), TaskScheduler.Default); var code = lifetime.Start(args); if (host is not null) host.DisposeAsync().AsTask().GetAwaiter().GetResult(); return code; From d382a5fe2b8271bb5da9965eb050654e4c6b0655 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:15:23 +0200 Subject: [PATCH 58/96] feat(core): keep the messages of each attempt and publish them as message events --- src/SqlRunboard.Core/BatchList.cs | 52 +++++++++++--- src/SqlRunboard.Core/Run.cs | 9 +++ tests/SqlRunboard.Core.Tests/MessageTests.cs | 73 ++++++++++++++++++++ 3 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 tests/SqlRunboard.Core.Tests/MessageTests.cs diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index 8464fd7..b5d9d2e 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -16,11 +16,15 @@ public sealed class BatchList(ISqlExecutor executor, TimeProvider time, string? readonly Lock gate = new(); readonly List batches = []; readonly Dictionary runs = []; + readonly Dictionary shown = []; // the run that each batch shows: its messages stay when the run ends readonly List> subscribers = []; readonly Dictionary passwords = []; // in memory only: never in a Batch, a file or an event readonly Dictionary loaded = []; // batch id -> the file text that the app last read or wrote int lastId, lastUntitled; + /// The server keeps the last 10,000 messages of each attempt. run.log has all of them. + public const int MaxMessages = 10_000; + public Batch NewBatch() { lock (gate) @@ -216,6 +220,7 @@ public bool Remove(int id) loaded.Remove(id); passwords.Remove(id); runs.Remove(id); + shown.Remove(id); Publish(new Change("remove", new { id })); return true; } @@ -261,7 +266,7 @@ public bool Remove(int id) var logParent = b.File is null ? appDataFolder : Path.GetDirectoryName(b.File); // ponytail: a log folder that cannot be made (read-only folder) stops the Run with the IO error; run without logs if users need that. var log = logParent is null ? null : RunLog.Create(logParent, b.Name, time, text => Warn(id, text)); - var run = runs[id] = new ActiveRun(time.GetUtcNow(), folder ?? "", b.Settings, log); + var run = runs[id] = shown[id] = new ActiveRun(time.GetUtcNow(), folder ?? "", b.Settings, log); foreach (var s in scripts.Where(s => s.Include && s.State != ScriptState.Missing)) { run.Scripts[s.Path] = new ScriptRun(s.Path, ScriptStatus.Queued, []); @@ -344,6 +349,7 @@ sealed class ActiveRun(DateTimeOffset start, string folder, BatchSettings settin public int Running { get; set; } public HashSet Cancelled { get; } = new(StringComparer.Ordinal); // Running scripts that you cancelled: not a timeout public Dictionary Tokens { get; } = new(StringComparer.Ordinal); // one per Running script + public Dictionary<(string Path, int Attempt), (Queue Kept, int Earlier)> Messages { get; } = []; public void Add(string path, Attempt a) { var s = Scripts[path]; Scripts[path] = s with { Status = a.Status, Attempts = [.. s.Attempts, a] }; } /// "Stop the queue at the first failure": no new scripts start. The Running scripts continue. @@ -357,8 +363,10 @@ public void Failed() } // Call under the lock. Starts Queued scripts up to the parallel limit, then publishes the run. + // The executors start after the publish, so the page sees an attempt before its first message. void Pump(int id, ActiveRun run) { + List after = []; while (run.Running < run.Settings.ParallelLimit && run.Queue.Count > 0) { var path = run.Queue[0]; @@ -370,26 +378,44 @@ void Pump(int id, ActiveRun run) try { parts = SqlScript.Read(Path.Combine(run.Folder, path)); } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { - run.Add(path, new Attempt(start, start, ScriptStatus.Failed, null, e is FileNotFoundException or DirectoryNotFoundException ? "File not found" : e.Message)); - run.Log?.Append($"{Attempt(run, path)}: Failed: {run.Scripts[path].Attempts[^1].Error}"); + var error = e is FileNotFoundException or DirectoryNotFoundException ? "File not found" : e.Message; + run.Add(path, new Attempt(start, start, ScriptStatus.Failed, null, error)); + run.Log?.Append($"{Attempt(run, path)}: Failed: {error}"); + var n = run.Scripts[path].Attempts.Count; + after.Add(() => AddMessage(id, run, path, n, new(SqlMessageKind.Error, error))); run.Failed(); continue; } run.Running++; run.Add(path, new Attempt(start, null, ScriptStatus.Running, null, null)); - var attempt = Attempt(run, path); + var (attempt, number) = (Attempt(run, path), run.Scripts[path].Attempts.Count); run.Log?.Append($"{attempt}: Started"); var execution = new SqlExecution(path, parts, new SqlConnectionSettings(run.Settings.Target, passwords.GetValueOrDefault(id))); // The continuation runs on the thread pool, never inline here under the lock. var cts = run.Tokens[path] = run.Settings.TimeoutSeconds is int s ? new CancellationTokenSource(TimeSpan.FromSeconds(s), time) : new CancellationTokenSource(); - _ = executor.ExecuteAsync(execution, m => run.Log?.Append($"{attempt}: {(m.Kind == SqlMessageKind.Error ? "Error: " : "")}{m.Text}"), cts.Token) - .ContinueWith(t => Finish(id, run, path, start, t), TaskScheduler.Default); + void OnMessage(SqlMessage m) + { + run.Log?.Append($"{attempt}: {(m.Kind == SqlMessageKind.Error ? "Error: " : "")}{m.Text}"); // outside the lock: a file write + lock (gate) AddMessage(id, run, path, number, m); + } + after.Add(() => executor.ExecuteAsync(execution, OnMessage, cts.Token).ContinueWith(t => Finish(id, run, path, start, t), TaskScheduler.Default)); } run.Log?.Update(run.Scripts.Values, run.Settings.Target); // also for a run that is no longer current: its late ends go to its log var i = batches.FindIndex(b => b.Id == id); - if (i < 0 || runs.GetValueOrDefault(id) != run) return; - var state = new RunState(run.Start, time.GetUtcNow(), [.. run.Scripts.Values], [.. run.Queue]); - Publish(new Change("batch", batches[i] = batches[i] with { Run = state })); + if (i >= 0 && runs.GetValueOrDefault(id) == run) + Publish(new Change("batch", batches[i] = batches[i] with { Run = new RunState(run.Start, time.GetUtcNow(), [.. run.Scripts.Values], [.. run.Queue]) })); + foreach (var a in after) a(); + } + + // Call under the lock. Keeps the last MaxMessages of the attempt, and sends the message to the page if the batch shows this run. + void AddMessage(int id, ActiveRun run, string path, int attempt, SqlMessage m) + { + var (kept, earlier) = run.Messages.GetValueOrDefault((path, attempt), ([], 0)); + var message = new RunMessage(time.GetUtcNow(), m.Kind, m.Text); + kept.Enqueue(message); + if (kept.Count > MaxMessages) { kept.Dequeue(); earlier++; } + run.Messages[(path, attempt)] = (kept, earlier); + if (shown.GetValueOrDefault(id) == run) Publish(new Change("message", new ScriptMessage(id, path, attempt, message.Time, m.Kind, m.Text))); } void Finish(int id, ActiveRun run, string path, DateTimeOffset start, Task t) @@ -404,6 +430,7 @@ void Finish(int id, ActiveRun run, string path, DateTimeOffset start, Task { lock (gate) subscribers.Remove(channel); }); + List messages = [.. shown.SelectMany(r => r.Value.Messages.Select(m => new AttemptMessages(r.Key, m.Key.Path, m.Key.Attempt, m.Value.Earlier, [.. m.Value.Kept])))]; + return new Subscription([.. batches], messages, channel.Reader, () => { lock (gate) subscribers.Remove(channel); }); } } @@ -456,9 +484,11 @@ void Publish(Change change) foreach (var s in subscribers) s.Writer.TryWrite(change); } - public sealed class Subscription(IReadOnlyList snapshot, ChannelReader changes, Action unsubscribe) : IDisposable + public sealed class Subscription(IReadOnlyList snapshot, IReadOnlyList messages, ChannelReader changes, Action unsubscribe) : IDisposable { public IReadOnlyList Snapshot => snapshot; + /// The kept messages of the runs that the batches show. + public IReadOnlyList Messages => messages; public ChannelReader Changes => changes; public void Dispose() => unsubscribe(); } diff --git a/src/SqlRunboard.Core/Run.cs b/src/SqlRunboard.Core/Run.cs index d4b3a3e..f574e0a 100644 --- a/src/SqlRunboard.Core/Run.cs +++ b/src/SqlRunboard.Core/Run.cs @@ -15,3 +15,12 @@ public sealed record RunState(DateTimeOffset Start, DateTimeOffset Now, IReadOnl { public bool Active => Scripts.Any(s => s.Status is ScriptStatus.Queued or ScriptStatus.Running); } + +/// One message of an attempt, for the drawer. Time is the server clock. +public sealed record RunMessage(DateTimeOffset Time, SqlMessageKind Kind, string Text); + +/// SSE "message": a new message of attempt number Attempt (1-based) of the script Path in the batch Id. +public sealed record ScriptMessage(int Id, string Path, int Attempt, DateTimeOffset Time, SqlMessageKind Kind, string Text); + +/// The kept messages of one attempt: the last . Earlier = the older ones, only in run.log. +public sealed record AttemptMessages(int Id, string Path, int Attempt, int Earlier, IReadOnlyList Messages); diff --git a/tests/SqlRunboard.Core.Tests/MessageTests.cs b/tests/SqlRunboard.Core.Tests/MessageTests.cs new file mode 100644 index 0000000..e1301e4 --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/MessageTests.cs @@ -0,0 +1,73 @@ +using SqlRunboard.Core; + +/// The messages of each attempt, for the drawer: live "message" events, and the kept messages in a new subscription. +public sealed class MessageTests : IDisposable +{ + readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-msg-").FullName; + readonly FakeTime time = new(); + readonly FakeSqlExecutor executor; + readonly BatchList list; + readonly BatchList.Subscription sub; + + public MessageTests() + { + executor = new FakeSqlExecutor(time); + list = new BatchList(executor, time); + sub = list.Subscribe(); + } + + Batch NewBatch(params string[] files) + { + foreach (var f in files) File.WriteAllText(Path.Combine(root, f), "select 1"); + var b = list.NewBatch(); + return list.UpdateSettings(b.Id, new BatchSettings { Folder = root })!; + } + + static SqlMessage Info(string text) => new(SqlMessageKind.Info, text); + + /// Reads the changes until the predicate matches. Returns all changes that it read. + async Task> ReadUntil(Func ok) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + List seen = []; + while (true) + { + var c = await sub.Changes.ReadAsync(cts.Token); + seen.Add(c); + if (ok(c)) return seen; + } + } + + static bool Ended(Change c, int attempts) => c.Data is Batch { Run: { Active: false } r } && r.Scripts.All(s => s.Attempts.Count == attempts); + + [Fact] + public async Task Executor_messages_are_published_live_after_the_attempt_starts_and_kept_for_a_new_subscriber() + { + var b = NewBatch("a.sql"); + executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(5), Messages = [Info("hello"), new(SqlMessageKind.RowCount, "3 rows affected")], Status = ScriptStatus.Failed, Error = "line 2 (part 1): boom" }; + + list.Run(b.Id); + time.Advance(TimeSpan.FromSeconds(5)); + var seen = await ReadUntil(c => Ended(c, 1)); + + var running = seen.FindIndex(c => c.Data is Batch { Run: { } r } && r.Scripts[0].Status == ScriptStatus.Running); + var messages = seen.Where(c => c.Kind == "message").Select(c => (ScriptMessage)c.Data).ToList(); + Assert.True(running >= 0 && running < seen.FindIndex(c => c.Kind == "message"), "the attempt shows Running before its first message"); + var start = new DateTimeOffset(2026, 10, 9, 12, 0, 0, TimeSpan.Zero); + Assert.Equal([ + new ScriptMessage(b.Id, "a.sql", 1, start, SqlMessageKind.Info, "hello"), + new ScriptMessage(b.Id, "a.sql", 1, start, SqlMessageKind.RowCount, "3 rows affected"), + new ScriptMessage(b.Id, "a.sql", 1, start.AddSeconds(5), SqlMessageKind.Error, "line 2 (part 1): boom")], messages); + + using var late = list.Subscribe(); + var kept = Assert.Single(late.Messages); + Assert.Equal((b.Id, "a.sql", 1, 0), (kept.Id, kept.Path, kept.Attempt, kept.Earlier)); + Assert.Equal(["hello", "3 rows affected", "line 2 (part 1): boom"], kept.Messages.Select(m => m.Text)); + } + + public void Dispose() + { + sub.Dispose(); + Directory.Delete(root, recursive: true); + } +} From b8eb568d60fdf724a128a0aac7577f7941a2eb66 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:15:52 +0200 Subject: [PATCH 59/96] feat(gui): confirm the target before Run, Rerun and Rerun all failed --- src/SqlRunboard.Gui/wwwroot/index.html | 16 ++- .../ConfirmBeforeRunTests.cs | 108 ++++++++++++++++++ 2 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 tests/SqlRunboard.Gui.Tests/ConfirmBeforeRunTests.cs diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 9154005..04f83ba 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -290,22 +290,26 @@ else if (r) then(r); render(); } +// The then of logon: "Confirm before run" shows the target, the login and n scripts before go. Cancel starts nothing. +const confirmed = (b, n, go) => r => b.settings.confirmBeforeRun ? openDialog({ kind: 'confirm', b, login: r.login, n, then: go }) : go(); function runBatch(b) { if (!b || b.missing || locked(b)) return; - logon(b, async () => { + logon(b, confirmed(b, b.scripts.filter(s => s.include && s.state !== 'Missing').length, async () => { const sb = await sendEdit(`/api/batches/${b.id}/run`, {}); if (sb) { upsert(sb); b.tab = 'run'; render(); } - }); + })); } const cancel = (b, path) => sendEdit(`/api/batches/${b.id}/cancel`, { path }); const cancelAll = b => sendEdit(`/api/batches/${b.id}/cancel-all`, {}); // The server decides: during a run it moves a Queued script in the queue, otherwise it changes the start order. dir: up, down, top, bottom. const move = (b, path, dir) => path && sendEdit(`/api/batches/${b.id}/move`, { path, to: dir }); const moveBatch = (b, dir) => sendEdit(`/api/batches/${b.id}/move-in-list`, { to: dir }); -// A rerun after the run ended checks the logon first, as Run does. A rerun during the run uses the kept password. -const afterLogon = (b, go) => b.run?.ended ? logon(b, go) : go(); -const rerun = (b, path) => afterLogon(b, () => sendEdit(`/api/batches/${b.id}/rerun`, { path })); -const rerunFailed = b => afterLogon(b, () => sendEdit(`/api/batches/${b.id}/rerun-failed`, {})); +// A rerun after the run ended checks the logon first, as Run does. A rerun during the run uses the kept password, +// but "Confirm before run" also checks the logon then: the dialog shows the login. +const afterLogon = (b, n, go) => b.run?.ended || b.settings.confirmBeforeRun ? logon(b, confirmed(b, n, go)) : go(); +const rerun = (b, path) => afterLogon(b, 1, () => sendEdit(`/api/batches/${b.id}/rerun`, { path })); +const rerunFailed = b => afterLogon(b, Object.values(b.run.rs).filter(r => r.status === 'Failed' || r.status === 'Cancelled').length, + () => sendEdit(`/api/batches/${b.id}/rerun-failed`, {})); function setInclude(b, s, include) { s.include = include; render(); sendEdit(`/api/batches/${b.id}/include`, { path: s.path, include }); } // The folder, Open and Save as dialogs: the server lists the folders (and the batch files). kind: folder, open or saveAs. async function browse(b, path, kind = 'folder') { diff --git a/tests/SqlRunboard.Gui.Tests/ConfirmBeforeRunTests.cs b/tests/SqlRunboard.Gui.Tests/ConfirmBeforeRunTests.cs new file mode 100644 index 0000000..455645e --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/ConfirmBeforeRunTests.cs @@ -0,0 +1,108 @@ +using Microsoft.Playwright; +using SqlRunboard.Core; + +/// "Confirm before run": the dialog before Run, Rerun and "Rerun all failed". +public class ConfirmBeforeRunTests : AppPageTest +{ + readonly string scripts = Directory.CreateTempSubdirectory("sqlrunboard-scripts-").FullName; + + ILocator Panel => Page.Locator("#panel"); + ILocator Header => Page.Locator("#hdr"); + ILocator Dialog => Page.GetByRole(AriaRole.Dialog); + ILocator RunButton => Header.GetByRole(AriaRole.Button, new() { Name = "Run", Exact = true }); + ILocator Cards(string column) => Panel.GetByRole(AriaRole.Region, new() { Name = column, Exact = true }).Locator(".card"); + + async Task OpenBatchAsync(params string[] files) + { + foreach (var f in files) + { + File.WriteAllText(Path.Combine(scripts, f), "select 1"); + App.Executor.Scripts[f] = new FakeScript { Duration = TimeSpan.FromSeconds(10) }; + } + App.Executor.Logon = c => new SqlLogon("SQL01", "Sales", "EXAMPLE\\hank"); + var b = App.Host.Batches.NewBatch(); + b = App.Host.Batches.UpdateSettings(b.Id, new BatchSettings + { + Folder = scripts, ConfirmBeforeRun = true, + Target = new() { Server = "sql01", Database = "Sales", Auth = Auth.Integrated }, + })!; + await Page.GetByRole(AriaRole.Navigation, new() { Name = "Batches" }).Locator($"[role=option]:has-text('{b.Name}')").ClickAsync(); + return b; + } + + async Task ExpectConfirmAsync(int scripts) + { + await Expect(Dialog.Locator("dd")).ToHaveTextAsync(["sql01", "Sales", "EXAMPLE\\hank", scripts.ToString()]); + } + + Task ClickAsync(string button) => Dialog.GetByRole(AriaRole.Button, new() { Name = button, Exact = true }).ClickAsync(); + + [Fact] + public async Task Run_asks_first_and_Cancel_starts_nothing() + { + await OpenBatchAsync("a.sql", "b.sql"); + + await RunButton.ClickAsync(); + await ExpectConfirmAsync(2); + await ClickAsync("Cancel"); + await Expect(Dialog).ToHaveCountAsync(0); + await Expect(RunButton).ToBeEnabledAsync(); + Assert.Empty(App.Executor.Calls); + + await RunButton.ClickAsync(); + await ExpectConfirmAsync(2); + await ClickAsync("Run"); + await Expect(Cards("Running")).ToHaveCountAsync(2); + } + + [Fact] + public async Task Rerun_and_Rerun_all_failed_ask_first_with_their_script_count() + { + var b = await OpenBatchAsync("a.sql", "b.sql", "c.sql"); + foreach (var f in new[] { "a.sql", "b.sql" }) + App.Executor.Scripts[f] = new FakeScript { Duration = TimeSpan.FromSeconds(10), Status = ScriptStatus.Failed }; + App.Host.Batches.Run(b.Id); + await Page.GetByRole(AriaRole.Tab, new() { Name = "Run" }).ClickAsync(); + await Expect(Cards("Running")).ToHaveCountAsync(3); + App.Time.Advance(TimeSpan.FromSeconds(10)); + await Expect(Cards("Failed / Cancelled")).ToHaveCountAsync(2); + + await Header.GetByRole(AriaRole.Button, new() { Name = "Rerun all failed" }).ClickAsync(); + await ExpectConfirmAsync(2); + await ClickAsync("Cancel"); + await Expect(Dialog).ToHaveCountAsync(0); + await Expect(Cards("Failed / Cancelled")).ToHaveCountAsync(2); + Assert.Equal(3, App.Executor.Calls.Count); + + await Cards("Succeeded").ClickAsync(new() { Button = MouseButton.Right }); + await Page.GetByRole(AriaRole.Menuitem, new() { Name = "Rerun" }).ClickAsync(); + await ExpectConfirmAsync(1); + await ClickAsync("Run"); + await Expect(Cards("Running")).ToHaveCountAsync(1); + Assert.Equal(4, App.Executor.Calls.Count); + } + + [Fact] + public async Task A_rerun_during_the_run_asks_first_too() + { + var b = await OpenBatchAsync("a.sql", "b.sql"); + App.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(5), Status = ScriptStatus.Failed }; + App.Executor.Scripts["b.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(60) }; + App.Host.Batches.Run(b.Id); + await Page.GetByRole(AriaRole.Tab, new() { Name = "Run" }).ClickAsync(); + await Expect(Cards("Running")).ToHaveCountAsync(2); + App.Time.Advance(TimeSpan.FromSeconds(5)); + await Expect(Cards("Failed / Cancelled")).ToHaveCountAsync(1); + + await Header.GetByRole(AriaRole.Button, new() { Name = "Rerun all failed" }).ClickAsync(); + await ExpectConfirmAsync(1); + await ClickAsync("Run"); + await Expect(Cards("Running")).ToHaveCountAsync(2); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + Directory.Delete(scripts, recursive: true); + } +} From 2ff61a34754b14dee72dce39d18305ff60da9bb2 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:16:11 +0200 Subject: [PATCH 60/96] feat(core): restore the side list and the last run from summary.csv --- src/SqlRunboard.Core/Batch.cs | 8 +- src/SqlRunboard.Core/BatchList.cs | 54 +++++- src/SqlRunboard.Core/RunLog.cs | 68 ++++++++ tests/SqlRunboard.Core.Tests/RestoreTests.cs | 166 +++++++++++++++++++ 4 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 tests/SqlRunboard.Core.Tests/RestoreTests.cs diff --git a/src/SqlRunboard.Core/Batch.cs b/src/SqlRunboard.Core/Batch.cs index 6a84259..903030f 100644 --- a/src/SqlRunboard.Core/Batch.cs +++ b/src/SqlRunboard.Core/Batch.cs @@ -43,8 +43,12 @@ public sealed record ScriptEntry(string Path, bool Include, [property: JsonIgnor public enum ScriptState { New, Missing } -/// One entry in the side list. File is null for an unsaved batch. Scripts is the start order. Run is the current run, or null. -public sealed record Batch(int Id, string Name, string? File, bool Dirty, BatchSettings Settings, IReadOnlyList Scripts, RunState? Run = null); +/// +/// One entry in the side list. File is null for an unsaved batch. Scripts is the start order. Run is the current run, or null. +/// FileError: the app could not read the batch file at the start (for example "File not found"). The entry stays until you remove it. +/// +public sealed record Batch(int Id, string Name, string? File, bool Dirty, BatchSettings Settings, IReadOnlyList Scripts, RunState? Run = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? FileError = null); /// The move commands of docs/SPEC.md "Script order". One item at a time. public enum MoveTo { Up, Down, Top, Bottom } diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index 8464fd7..c425248 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -20,6 +20,32 @@ public sealed class BatchList(ISqlExecutor executor, TimeProvider time, string? readonly Dictionary passwords = []; // in memory only: never in a Batch, a file or an event readonly Dictionary loaded = []; // batch id -> the file text that the app last read or wrote int lastId, lastUntitled; + string[] files = []; + + /// + /// Called (under the lock) with the batch file paths in side-list order each time they change. The app keeps them in its settings, + /// so Restore can bring the side list back after a restart. Unsaved batches have no file and do not come back. + /// + public Action>? FilesChanged { get; set; } + + /// + /// The start of the app: adds the batch files in this order. A file that cannot be read stays in the list with a FileError. + /// + public void Restore(IEnumerable paths) + { + lock (gate) + foreach (var file in paths.Distinct(StringComparer.Ordinal)) + { + var b = new Batch(++lastId, BatchFile.NameOf(file), file, Dirty: false, new BatchSettings(), Scripts: []); + try { b = LastRun(Load(b)); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or InvalidDataException) + { + b = b with { FileError = e is FileNotFoundException or DirectoryNotFoundException ? "File not found" : e.Message }; + } + batches.Add(b); + Publish(new Change("batch", b)); + } + } public Batch NewBatch() { @@ -203,7 +229,29 @@ Batch Load(Batch b) var text = File.ReadAllText(b.File!); var (settings, scripts) = BatchFile.Parse(text); loaded[b.Id] = text; - return b with { Dirty = false, Settings = settings, Scripts = Merge(scripts, FileScan.Scan(FolderPath(b.File, settings.Folder), settings.Filters, settings.Recurse)), Run = EndRun(b, settings) }; + return b with { Dirty = false, Settings = settings, Scripts = Merge(scripts, FileScan.Scan(FolderPath(b.File, settings.Folder), settings.Filters, settings.Recurse)), Run = EndRun(b, settings), FileError = null }; + } + + /// + /// Call under the lock, at the start. The batch gets the run in its newest summary.csv. The rerun commands work on that run, + /// in its log folder, unless the server or database in the file is not the target of the batch: then the run has ended. + /// + Batch LastRun(Batch b) + { + if (RunLog.Newest(Path.GetDirectoryName(b.File)!, b.Name) is not { } folder) return b; + List scripts; + string server, database; + try { (scripts, server, database) = RunLog.ReadSummary(folder); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or FormatException or ArgumentException or OverflowException) { return b; } // no last run to show + var start = scripts.SelectMany(s => s.Attempts).Select(a => a.Start).DefaultIfEmpty(time.GetUtcNow()).Min(); + var target = b.Settings.Target; + var ended = scripts.Count > 0 && (server != (target.Server ?? "") || database != (target.Database ?? "")); + if (!ended) + { + var run = runs[b.Id] = new ActiveRun(start, FolderPath(b.File, b.Settings.Folder) ?? "", b.Settings, RunLog.Open(folder, time, text => Warn(b.Id, text))); + foreach (var s in scripts) run.Scripts[s.Path] = s; + } + return b with { Run = new RunState(start, time.GetUtcNow(), scripts, [], ended) }; } /// "Remove from list". The file stays on disk. The run of the batch is cancelled. False if no batch has this id. @@ -454,6 +502,10 @@ public Subscription Subscribe() void Publish(Change change) { foreach (var s in subscribers) s.Writer.TryWrite(change); + string[] now = [.. batches.Where(b => b.File is not null).Select(b => b.File!)]; + if (now.SequenceEqual(files)) return; + files = now; + FilesChanged?.Invoke(now); } public sealed class Subscription(IReadOnlyList snapshot, ChannelReader changes, Action unsubscribe) : IDisposable diff --git a/src/SqlRunboard.Core/RunLog.cs b/src/SqlRunboard.Core/RunLog.cs index f9eb4c1..b41b9cb 100644 --- a/src/SqlRunboard.Core/RunLog.cs +++ b/src/SqlRunboard.Core/RunLog.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Text; +using System.Text.RegularExpressions; namespace SqlRunboard.Core; @@ -31,6 +32,73 @@ public static RunLog Create(string parent, string batchName, TimeProvider time, return new RunLog(folder, time, warn); } + /// The log folder of an earlier run (after a restart): reruns add to it. Its run is not active. + public static RunLog Open(string folder, TimeProvider time, Action warn) => new(folder, time, warn) { active = false }; + + /// The newest log folder of the batch under parent/logs that has a summary.csv, or null. + public static string? Newest(string parent, string batchName) + { + var logs = Path.Combine(parent, "logs"); + if (!Directory.Exists(logs)) return null; + var name = new Regex($@"^{Regex.Escape(batchName)}_\d{{8}}-\d{{6}}(-\d+)?$"); + // ponytail: the newest by folder name (local start time); a run in the repeated hour at a DST change can sort wrong. + return Directory.EnumerateDirectories(logs).Where(d => name.IsMatch(Path.GetFileName(d)) && File.Exists(Path.Combine(d, SummaryFile))) + .OrderBy(Path.GetFileName, FileScan.NaturalOrder).LastOrDefault(); + } + + /// + /// Reads summary.csv back: the scripts in the start order, and the server and database of the run. + /// A Queued or Running row changes to Cancelled with "The app stopped". Throws IOException or FormatException. + /// + public static (List Scripts, string Server, string Database) ReadSummary(string folder) + { + var rows = ParseCsv(File.ReadAllText(Path.Combine(folder, SummaryFile))).Skip(1).Where(r => r.Count >= 10).ToList(); + static DateTimeOffset? Time(string v) => v == "" ? null : DateTimeOffset.Parse(v, CultureInfo.InvariantCulture); + // A Running attempt ends at the last time that the file knows: its last status change. + var last = rows.SelectMany(r => new[] { Time(r[3]), Time(r[4]) }).Max(); + var scripts = new OrderedDictionary(StringComparer.Ordinal); + foreach (var r in rows) + { + var status = Enum.Parse(r[2]); + var stopped = status is ScriptStatus.Queued or ScriptStatus.Running; + var error = stopped ? "The app stopped" : r[7] == "" ? null : r[7]; + if (stopped) status = ScriptStatus.Cancelled; + var s = scripts.GetValueOrDefault(r[0]) ?? new ScriptRun(r[0], status, []); + scripts[r[0]] = r[1] == "0" ? s with { Status = status, Error = error } + : s with { Status = status, Attempts = [.. s.Attempts, new Attempt(Time(r[3])!.Value, Time(r[4]) ?? last, status, r[6] == "" ? null : long.Parse(r[6], CultureInfo.InvariantCulture), error)] }; + } + return ([.. scripts.Values], rows.FirstOrDefault()?[8] ?? "", rows.FirstOrDefault()?[9] ?? ""); + } + + /// RFC 4180: quoted fields can have commas, "" and line breaks. + static IEnumerable> ParseCsv(string text) + { + List row = []; + var field = new StringBuilder(); + bool quoted = false, any = false; + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + any = true; + if (quoted) + { + if (c != '"') field.Append(c); + else if (i + 1 < text.Length && text[i + 1] == '"') { field.Append('"'); i++; } + else quoted = false; + } + else if (c == '"') quoted = true; + else if (c == ',') { row.Add(field.ToString()); field.Clear(); } + else if (c == '\n') + { + row.Add(field.ToString().TrimEnd('\r')); field.Clear(); + yield return row; + (row, any) = ([], false); + } + else field.Append(c); + } + if (any) { row.Add(field.ToString()); yield return row; } + } + /// Adds one line to run.log, with the local time. public void Append(string text) { diff --git a/tests/SqlRunboard.Core.Tests/RestoreTests.cs b/tests/SqlRunboard.Core.Tests/RestoreTests.cs new file mode 100644 index 0000000..abf0ad1 --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/RestoreTests.cs @@ -0,0 +1,166 @@ +using SqlRunboard.Core; + +/// A restart: a new BatchList gets the batch file paths that the old one reported, and reads the last runs from disk. +public sealed class RestoreTests : IDisposable +{ + readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-restore-").FullName; + readonly FakeTime time = new(); + readonly FakeSqlExecutor executor; + readonly BatchList list; + readonly BatchList.Subscription sub; + IReadOnlyList files = []; + + public RestoreTests() + { + executor = new FakeSqlExecutor(time); + list = new BatchList(executor, time) { FilesChanged = f => files = f }; + sub = list.Subscribe(); + } + + public void Dispose() + { + sub.Dispose(); + Directory.Delete(root, recursive: true); + } + + /// A saved batch with these scripts in root/<name>/. + Batch Saved(string name, BatchTarget? target = null, params string[] scripts) + { + var folder = Directory.CreateDirectory(Path.Combine(root, name)).FullName; + foreach (var s in scripts) File.WriteAllText(Path.Combine(folder, s), "select 1"); + var b = list.NewBatch(); + list.UpdateSettings(b.Id, new BatchSettings { Folder = folder, ParallelLimit = 1, Target = target ?? new() }); + return list.Save(b.Id, Path.Combine(folder, name), overwrite: false)!; + } + + async Task Until(int id, Func ok) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (true) + { + var change = await sub.Changes.ReadAsync(cts.Token); + if (change.Data is Batch { Run: { } run } b && b.Id == id && ok(run)) return run; + } + } + + BatchList Restart() + { + var again = new BatchList(executor, time); + again.Restore(files); + return again; + } + + [Fact] + public void The_side_list_comes_back_in_the_same_order_without_the_unsaved_batches() + { + var a = Saved("a"); + list.NewBatch(); + var b = Saved("b"); + list.MoveBatch(b.Id, MoveTo.Top); + + var batches = Restart().Subscribe().Snapshot; + + Assert.Equal(["b", "a"], batches.Select(x => x.Name)); + Assert.Equal([b.File, a.File], batches.Select(x => x.File)); + Assert.All(batches, x => Assert.Null(x.FileError)); + } + + [Fact] + public void A_missing_batch_file_stays_with_File_not_found_until_you_remove_it() + { + var a = Saved("a"); + Saved("b"); + File.Delete(a.File!); + + var again = Restart(); + var missing = again.Subscribe().Snapshot[0]; + + Assert.Equal("a", missing.Name); + Assert.Equal("File not found", missing.FileError); + List> reported = []; + again.FilesChanged = reported.Add; + Assert.True(again.Remove(missing.Id)); + Assert.Equal(["b"], again.Subscribe().Snapshot.Select(x => x.Name)); + Assert.Equal([Path.Combine(root, "b", "b.sqlrunboard.json")], reported.Single()); + } + + /// Runs "a" twice. The second (newest) run stops with 1.sql Succeeded, 2.sql Failed, 3.sql Running and 4.sql Queued. + async Task InterruptedRun(BatchTarget? target = null) + { + var a = Saved("a", target, "1.sql", "2.sql", "3.sql", "4.sql"); + list.Run(a.Id); + await Until(a.Id, r => !r.Active); + time.Advance(TimeSpan.FromMinutes(1)); + executor.Scripts["1.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(5), Rows = 7 }; + executor.Scripts["2.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(5), Status = ScriptStatus.Failed, Error = "boom, \"quoted\"\nline 2" }; + executor.Scripts["3.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(100) }; + list.Run(a.Id); + await Until(a.Id, r => r.Scripts[0].Status == ScriptStatus.Running); + time.Advance(TimeSpan.FromSeconds(5)); + await Until(a.Id, r => r.Scripts[1].Status == ScriptStatus.Running); + time.Advance(TimeSpan.FromSeconds(5)); + await Until(a.Id, r => r.Scripts[2].Status == ScriptStatus.Running); + return a; + } + + [Fact] + public async Task Each_batch_shows_its_last_run_and_Queued_or_Running_scripts_show_as_Cancelled() + { + await InterruptedRun(); + + var run = Restart().Subscribe().Snapshot[0].Run!; + + var start = new DateTimeOffset(2026, 10, 9, 12, 1, 0, TimeSpan.Zero); + Assert.Equal(start, run.Start); + Assert.False(run.Ended); + Assert.False(run.Active); + Assert.Empty(run.Queue); + Assert.Equal(["1.sql", "2.sql", "3.sql", "4.sql"], run.Scripts.Select(s => s.Path)); + Assert.Equal([ScriptStatus.Succeeded, ScriptStatus.Failed, ScriptStatus.Cancelled, ScriptStatus.Cancelled], run.Scripts.Select(s => s.Status)); + Assert.Equal(new Attempt(start, start.AddSeconds(5), ScriptStatus.Succeeded, 7, null), run.Scripts[0].Attempts.Single()); + Assert.Equal("boom, \"quoted\"\nline 2", run.Scripts[1].Attempts.Single().Error); + var stopped = run.Scripts[2].Attempts.Single(); + Assert.Equal((start.AddSeconds(10), ScriptStatus.Cancelled, "The app stopped"), (stopped.Start, stopped.Status, stopped.Error)); + Assert.NotNull(stopped.End); + Assert.Equal(("4.sql", ScriptStatus.Cancelled, "The app stopped"), (run.Scripts[3].Path, run.Scripts[3].Status, run.Scripts[3].Error)); + Assert.Empty(run.Scripts[3].Attempts); + } + + [Fact] + public async Task Rerun_all_failed_continues_an_interrupted_run_in_its_log_folder() + { + var a = await InterruptedRun(); + executor.Scripts.Clear(); + + var again = Restart(); + using var sub2 = again.Subscribe(); + var id = sub2.Snapshot[0].Id; + again.RerunFailed(id); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + RunState run; + do run = ((Batch)(await sub2.Changes.ReadAsync(cts.Token)).Data).Run!; + while (run.Active || run.Scripts[3].Attempts.Count == 0); + Assert.Equal([1, 2, 2, 1], run.Scripts.Select(s => s.Attempts.Count)); + Assert.All(run.Scripts, s => Assert.Equal(ScriptStatus.Succeeded, s.Status)); + + var logs = Directory.GetDirectories(Path.Combine(Path.GetDirectoryName(a.File)!, "logs")).Order().ToArray(); + Assert.Equal(2, logs.Length); // no new log folder + var csv = File.ReadAllText(Path.Combine(logs[1], "summary.csv")); + Assert.Contains("3.sql,2,Succeeded", csv); + Assert.Contains("4.sql,1,Succeeded", csv); + } + + [Fact] + public async Task A_target_change_while_the_app_was_stopped_ends_the_run() + { + var a = await InterruptedRun(new BatchTarget { Server = "sql01", Database = "Sales" }); + File.WriteAllText(a.File!, File.ReadAllText(a.File!).Replace("\"sql01\"", "\"sql02\"")); // for example after a pull from the repository + + var again = Restart(); + var b = again.Subscribe().Snapshot[0]; + + Assert.True(b.Run!.Ended); + Assert.Equal(b, again.RerunFailed(b.Id)); // the rerun commands do nothing + } +} From c4a63d6223a680f0735f0c92219c4e1cac7492d0 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:16:17 +0200 Subject: [PATCH 61/96] test(core): cover the 10,000 message limit and engine errors as messages --- tests/SqlRunboard.Core.Tests/MessageTests.cs | 33 ++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/SqlRunboard.Core.Tests/MessageTests.cs b/tests/SqlRunboard.Core.Tests/MessageTests.cs index e1301e4..b5c9613 100644 --- a/tests/SqlRunboard.Core.Tests/MessageTests.cs +++ b/tests/SqlRunboard.Core.Tests/MessageTests.cs @@ -65,6 +65,39 @@ public async Task Executor_messages_are_published_live_after_the_attempt_starts_ Assert.Equal(["hello", "3 rows affected", "line 2 (part 1): boom"], kept.Messages.Select(m => m.Text)); } + [Fact] + public async Task An_attempt_keeps_its_last_10000_messages_and_counts_the_earlier_ones() + { + var b = NewBatch("a.sql"); + executor.Scripts["a.sql"] = new FakeScript { Messages = [.. Enumerable.Range(1, BatchList.MaxMessages + 5).Select(i => Info($"m{i}"))] }; + + list.Run(b.Id); + await ReadUntil(c => Ended(c, 1)); + + using var late = list.Subscribe(); + var kept = Assert.Single(late.Messages); + Assert.Equal(5, kept.Earlier); + Assert.Equal(10_000, kept.Messages.Count); + Assert.Equal(("m6", "m10005"), (kept.Messages[0].Text, kept.Messages[^1].Text)); + } + + [Fact] + public async Task Errors_of_the_engine_are_messages_of_their_attempt_too() + { + var b = NewBatch("1-slow.sql", "2-gone.sql"); + list.UpdateSettings(b.Id, b.Settings with { TimeoutSeconds = 3, ParallelLimit = 1 }); + executor.Scripts["1-slow.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(60) }; + list.Run(b.Id); + File.Delete(Path.Combine(root, "2-gone.sql")); // the Queued script fails when it starts + time.Advance(TimeSpan.FromSeconds(3)); + await ReadUntil(c => Ended(c, 1)); + + using var late = list.Subscribe(); + Assert.Equal([("1-slow.sql", 1, "Timeout after 3 s"), ("2-gone.sql", 1, "File not found")], + late.Messages.Select(m => (m.Path, m.Attempt, Assert.Single(m.Messages).Text))); + Assert.All(late.Messages, m => Assert.Equal(SqlMessageKind.Error, m.Messages[0].Kind)); + } + public void Dispose() { sub.Dispose(); From 54ba8fabb49ac3328d57d4170bfbcb0f2c18b7f5 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:18:16 +0200 Subject: [PATCH 62/96] feat(gui): show live attempt messages, the run.log note and log warnings in the drawer --- src/SqlRunboard.Gui/GuiHost.cs | 2 +- src/SqlRunboard.Gui/wwwroot/index.html | 29 +++++-- tests/SqlRunboard.Gui.Tests/DrawerTests.cs | 99 ++++++++++++++++++++++ tests/SqlRunboard.Gui.Tests/RunLogTests.cs | 2 + 4 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 tests/SqlRunboard.Gui.Tests/DrawerTests.cs diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index aae7052..18e064e 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -177,7 +177,7 @@ void WriteLock() static async IAsyncEnumerable> Events(BatchList list, AppSettings settings, [EnumeratorCancellation] CancellationToken ct) { using var sub = list.Subscribe(); - yield return new SseItem(new { batches = sub.Snapshot, app = settings.Snapshot() }, "snapshot"); + yield return new SseItem(new { batches = sub.Snapshot, app = settings.Snapshot(), messages = sub.Messages }, "snapshot"); await foreach (var change in sub.Changes.ReadAllAsync(ct)) yield return new SseItem(change.Data, change.Kind); } diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 9154005..f028229 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -215,7 +215,7 @@ function toRun(r) { if (!r) return null; const t0 = Date.parse(r.start), sec = x => x == null ? null : (Date.parse(x) - t0) / 1000, skew = Date.parse(r.now) - Date.now(), rs = {}; - for (const s of r.scripts) rs[s.path] = { status: s.status, error: s.error, msgs: [], attempts: s.attempts.map((a, i) => ({ n: i + 1, start: sec(a.start), end: sec(a.end), status: a.status, rows: a.rows, error: a.error })) }; + for (const s of r.scripts) rs[s.path] = { status: s.status, error: s.error, attempts: s.attempts.map((a, i) => ({ n: i + 1, start: sec(a.start), end: sec(a.end), status: a.status, rows: a.rows, error: a.error })) }; const ended = !r.scripts.some(s => s.status === 'Queued' || s.status === 'Running'); return { rs, queue: r.queue, wall: new Date(t0), live: true, ended, stale: r.ended, endAt: ended ? Math.max(0, ...Object.values(rs).flatMap(x => x.attempts.map(a => a.end ?? 0))) : null, get t() { return (Date.now() + skew - t0) / 1000; } }; } @@ -225,6 +225,7 @@ function upsert(sb) { let b = app.batches.find(x => x.id === sb.id); if (!b) app.batches.push(b = { missing: false, tab: null, sel: null, drawer: false, run: null, scripts: [], signing: null, logonMsg: null, testMsg: null, sort: null, fileSel: null }); + if (!b.msgs || sb.run?.start !== b.runStart) { b.msgs = {}; b.warnings = []; b.runStart = sb.run?.start; } // a new run: its messages come after this event const mine = b.saving ? b.settings : null; // while my settings are on the way, an older echo must not undo them Object.assign(b, toView(sb)); if (mine) b.settings = mine; @@ -237,6 +238,16 @@ } return res.json(); } +// The drawer messages: b.msgs[path][attempt] = { earlier, list }. The page keeps the last MAX_MSGS of each attempt, as the server does. +const MAX_MSGS = 10000; +function addMsg(b, path, a, m) { + const at = ((b.msgs[path] ??= {})[a] ??= { earlier: 0, list: [] }); + at.list.push(m); + if (at.list.length > MAX_MSGS) { at.list.shift(); at.earlier++; } +} +// Many messages can come at once: render once for them. +let renderTimer = null; +const renderSoon = () => { renderTimer ??= setTimeout(() => { renderTimer = null; render(); }, 50); }; // The side list in the server order. A batch that is not in ids drops out. const reorder = ids => { app.batches = ids.map(id => app.batches.find(b => b.id === id)).filter(Boolean); }; // SSE: the full state first ("snapshot"), then one event per change. EventSource sends the auth cookie and reconnects by itself. @@ -247,13 +258,17 @@ for (const k in PREFS) if (k in snap.app) app[k] = snap.app[k]; applyTheme(); setDrawerWidth(app.drawerWidth); reorder(snap.batches.map(sb => upsert(sb).id)); + for (const b of app.batches) b.msgs = {}; + for (const m of snap.messages) { const b = app.batches.find(x => x.id === m.id); if (b) b.msgs[m.path] = { ...b.msgs[m.path], [m.attempt]: { earlier: m.earlier, list: m.messages } }; } if (!app.batches.some(b => b.id === app.cur)) app.cur = app.batches[0]?.id ?? null; document.documentElement.dataset.connected = ''; // tests wait for this render(); }); es.addEventListener('batch', e => { upsert(JSON.parse(e.data)); render(); }); es.addEventListener('remove', e => dropBatch(JSON.parse(e.data).id)); - es.addEventListener('warning', e => { const w = JSON.parse(e.data); toast(`${app.batches.find(b => b.id === w.id)?.name ?? 'Batch'}: ${w.text}`); }); + es.addEventListener('message', e => { const m = JSON.parse(e.data), b = app.batches.find(x => x.id === m.id); if (b) { addMsg(b, m.path, m.attempt, m); renderSoon(); } }); + // ponytail: the warnings live in the page only (lost at a reload); run.log keeps them. + es.addEventListener('warning', e => { const w = JSON.parse(e.data), b = app.batches.find(x => x.id === w.id); b?.warnings.push(w.text); toast(`${b?.name ?? 'Batch'}: ${w.text}`); render(); }); es.addEventListener('order', e => { reorder(JSON.parse(e.data).ids); render(); }); es.onerror = () => { delete document.documentElement.dataset.connected; }; } @@ -375,7 +390,7 @@ } // one row per script: the run rows, or the Queued rows before the first run function rowsFor(b) { - if (!b.run) return b.scripts.filter(s => s.include && s.state !== 'Missing').map((s, i) => ({ path: s.path, r: { status: 'Queued', attempts: [], msgs: [], virtual: true }, q: i + 1 })); + if (!b.run) return b.scripts.filter(s => s.include && s.state !== 'Missing').map((s, i) => ({ path: s.path, r: { status: 'Queued', attempts: [], virtual: true }, q: i + 1 })); return Object.entries(b.run.rs).map(([path, r]) => ({ path, r, q: r.status === 'Queued' ? b.run.queue.indexOf(path) + 1 : null })); } function runOrder(b) { // start of the first attempt; Queued at the bottom in queue order @@ -562,13 +577,17 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} const x = rowsFor(b).find(y => y.path === b.sel); if (!x) return ''; const r = x.r, a = lastA(r), live = b.run && !b.run.ended, mv = canMove(b, x.path); - let body = ''; + let body = b.warnings.map(w => `
${esc(w)}
`).join(''); // log write warnings of the run if (r.virtual) body = '

No run yet.

'; else if (!r.attempts.length && r.error) body = `
${esc(r.error)}
`; // did not start for (const at of r.attempts) { body += `
Attempt ${at.n}${at.start != null ? ` · ${clock(b.run, at.start)}` : ''} · ${at.status}
`; if (at.restored) body += `${at.error ? `
${lineLink(at.error)}
` : ''}
Full messages: run.log
`; - else body += r.msgs.filter(m => m.a === at.n).map(m => `
${clock(b.run, m.t)} ${m.kind === 'Error' ? lineLink(m.text) : esc(m.text)}
`).join(''); + else { + const ms = b.msgs[x.path]?.[at.n]; + if (ms?.earlier) body += `
${ms.earlier.toLocaleString()} earlier messages are in run.log
`; + body += (ms?.list ?? []).map(m => `
${new Date(m.time).toLocaleTimeString([], { hour12: false })} ${m.kind === 'Error' ? lineLink(m.text) : esc(m.text)}
`).join(''); + } } return `
${bubble(r.status)}${esc(x.path)}
diff --git a/tests/SqlRunboard.Gui.Tests/DrawerTests.cs b/tests/SqlRunboard.Gui.Tests/DrawerTests.cs new file mode 100644 index 0000000..80de30c --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/DrawerTests.cs @@ -0,0 +1,99 @@ +using Microsoft.Playwright; +using SqlRunboard.Core; + +public class DrawerTests : AppPageTest +{ + readonly string scripts = Directory.CreateTempSubdirectory("sqlrunboard-scripts-").FullName; + + ILocator Panel => Page.Locator("#panel"); + ILocator Drawer => Page.Locator("#drawer"); + ILocator Msgs => Drawer.Locator(".msgs"); + ILocator Card(string path) => Panel.Locator($".card:has-text('{path}')"); + ILocator DrawerButton(string name) => Drawer.GetByRole(AriaRole.Button, new() { Name = name, Exact = true }); + ILocator Side(string name) => Page.GetByRole(AriaRole.Navigation, new() { Name = "Batches" }).Locator($"[role=option]:has-text('{name}')"); + // The batch events can come after the click on the side list, so open the Run tab by hand. + async Task OpenRun(string name) { await Side(name).ClickAsync(); await Page.GetByRole(AriaRole.Tab, new() { Name = "Run" }).ClickAsync(); } + + Batch RunBatch(int limit, params string[] files) + { + foreach (var f in files) File.WriteAllText(Path.Combine(scripts, f), "select 1"); + var b = App.Host.Batches.NewBatch(); + App.Host.Batches.UpdateSettings(b.Id, new BatchSettings { Folder = scripts, ParallelLimit = limit }); + return App.Host.Batches.Run(b.Id)!; + } + + [Fact] + public async Task Selecting_a_script_opens_the_drawer_beside_the_board_with_one_live_stream_for_all_attempts() + { + App.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(10), Messages = [new(SqlMessageKind.Info, "hello")], Status = ScriptStatus.Failed, Error = "line 2 (part 1): boom" }; + App.Executor.Scripts["b.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(60) }; + var b = RunBatch(2, "a.sql", "b.sql"); + await OpenRun(b.Name); + await Card("a.sql").ClickAsync(); + + await Expect(Msgs).ToContainTextAsync("hello"); + var panel = (await Panel.BoundingBoxAsync())!; + var drawer = (await Drawer.BoundingBoxAsync())!; + Assert.True(panel.X + panel.Width <= drawer.X + 1, "the drawer is beside the board, not over it"); + + App.Time.Advance(TimeSpan.FromSeconds(10)); + await Expect(Msgs.Locator(".Error")).ToContainTextAsync("line 2 (part 1): boom"); + + await DrawerButton("Rerun").ClickAsync(); // b.sql still runs, so the page posts the rerun at once + await Expect(Msgs.Locator(".sep")).ToHaveCountAsync(2); + await Expect(Msgs.Locator(".sep").Nth(1)).ToContainTextAsync("Attempt 2"); + await Expect(Msgs.Locator(".Info")).ToHaveTextAsync([new System.Text.RegularExpressions.Regex("hello$"), new System.Text.RegularExpressions.Regex("hello$")]); + await DrawerButton("Cancel").ClickAsync(); + await Expect(Card("a.sql").Locator(".r2")).ToContainTextAsync("Cancelled"); + } + + [Fact] + public async Task The_move_buttons_of_the_drawer_move_a_Queued_script() + { + App.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(60) }; + var b = RunBatch(1, "a.sql", "b.sql", "c.sql"); + await OpenRun(b.Name); + await Card("c.sql").ClickAsync(); + + await DrawerButton("Move to top").ClickAsync(); + await Expect(Panel.GetByRole(AriaRole.Region, new() { Name = "Queued" }).Locator(".card .r1 span:not(.st)")).ToHaveTextAsync(["c.sql", "b.sql"]); + } + + [Fact] + public async Task The_table_uses_the_same_selection_and_drawer_as_the_board() + { + App.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(60) }; + var b = RunBatch(1, "a.sql", "b.sql"); + await OpenRun(b.Name); + await Card("b.sql").ClickAsync(); + await Expect(Drawer.Locator(".dh .t")).ToHaveTextAsync("○b.sql"); + + await Panel.GetByRole(AriaRole.Group, new() { Name = "View" }).GetByRole(AriaRole.Button, new() { Name = "Table" }).ClickAsync(); + await Expect(Drawer.Locator(".dh .t")).ToHaveTextAsync("○b.sql"); + await Panel.Locator("tr:has-text('a.sql')").ClickAsync(); + await Expect(Drawer.Locator(".dh .t")).ToHaveTextAsync("▶a.sql"); + } + + [Fact] + public async Task An_attempt_with_more_than_10000_messages_names_the_earlier_ones_in_run_log() + { + App.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(5), Messages = [.. Enumerable.Range(1, BatchList.MaxMessages + 5).Select(i => new SqlMessage(SqlMessageKind.Info, $"m{i}"))] }; + var b = RunBatch(1, "a.sql"); + await OpenRun(b.Name); + await Card("a.sql").ClickAsync(); + await Expect(Msgs).ToContainTextAsync("5 earlier messages are in run.log"); + await Expect(Msgs.Locator(".Info").First).ToContainTextAsync("m6"); + + await Page.ReloadAsync(); + await OpenRun(b.Name); + await Card("a.sql").ClickAsync(); + await Expect(Msgs).ToContainTextAsync("5 earlier messages are in run.log"); + await Expect(Msgs.Locator(".Info")).ToHaveCountAsync(BatchList.MaxMessages); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + Directory.Delete(scripts, recursive: true); + } +} diff --git a/tests/SqlRunboard.Gui.Tests/RunLogTests.cs b/tests/SqlRunboard.Gui.Tests/RunLogTests.cs index 3043c77..5ee6914 100644 --- a/tests/SqlRunboard.Gui.Tests/RunLogTests.cs +++ b/tests/SqlRunboard.Gui.Tests/RunLogTests.cs @@ -25,5 +25,7 @@ public async Task A_run_of_an_unsaved_batch_logs_in_app_data_and_a_failed_summar App.Time.Advance(TimeSpan.FromSeconds(5)); await Expect(Page.Locator("#toast")).ToContainTextAsync($"{b.Name}: Warning: cannot write summary.csv"); + await Page.Locator("#panel .card:has-text('b.sql')").ClickAsync(); + await Expect(Page.Locator("#drawer .msgs")).ToContainTextAsync("Warning: cannot write summary.csv"); // the drawer of each script of the run } } From ef349a15d39e948748c1ef99eea4a78107eba89e Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:18:54 +0200 Subject: [PATCH 63/96] feat(gui): keep the side list in the app settings and restore it at the start --- src/SqlRunboard.Core/BatchList.cs | 2 +- src/SqlRunboard.Gui/GuiHost.cs | 10 ++++ src/SqlRunboard.Gui/wwwroot/index.html | 6 +-- tests/SqlRunboard.Gui.Tests/RestartTests.cs | 60 +++++++++++++++++++++ 4 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 tests/SqlRunboard.Gui.Tests/RestartTests.cs diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index c425248..e7d95ea 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -34,7 +34,7 @@ public sealed class BatchList(ISqlExecutor executor, TimeProvider time, string? public void Restore(IEnumerable paths) { lock (gate) - foreach (var file in paths.Distinct(StringComparer.Ordinal)) + foreach (var file in paths.Where(p => Path.IsPathFullyQualified(p) && BatchFile.HasExtension(p)).Distinct(StringComparer.Ordinal)) { var b = new Batch(++lastId, BatchFile.NameOf(file), file, Dirty: false, new BatchSettings(), Scripts: []); try { b = LastRun(Load(b)); } diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index aae7052..c79e11d 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -67,6 +67,7 @@ public static async Task StartAsync(GuiHostOptions options) builder.Services.ConfigureHttpJsonOptions(j => j.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); var app = builder.Build(); + RestoreBatches(app.Services.GetRequiredService(), app.Services.GetRequiredService()); app.Use(RejectForeignRequests); app.Use(RequireToken(token)); var files = new EmbeddedFileProvider(typeof(GuiHost).Assembly, "SqlRunboard.Gui.wwwroot"); @@ -133,6 +134,15 @@ public static async Task StartAsync(GuiHostOptions options) return host; } + /// The side list comes back from the app settings, and each change of its batch files goes there. + static void RestoreBatches(BatchList list, AppSettings settings) + { + const string Name = "batchFiles"; + var files = settings.Snapshot()[Name] as JsonArray ?? []; + list.Restore(files.Where(f => f?.GetValueKind() == JsonValueKind.String).Select(f => f!.GetValue())); + list.FilesChanged = f => settings.Merge(new JsonObject { [Name] = new JsonArray([.. f.Select(x => (JsonNode)x)]) }); + } + /// A batch file command: the batch, or a problem whose detail the page shows. 409: the file changed on disk. static IResult FileResult(Func command) { diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 9154005..3e38551 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -209,7 +209,7 @@ // ---------- server state ---------- // The server keeps all batch state. Each server batch merges into a page object that also keeps the view state (tab, selection, drawer). // The page uses the flat settings of the prototype; the server uses the batch file shape (target is nested). -const toView = sb => ({ ...sb, run: toRun(sb.run), settings: { ...sb.settings, ...sb.settings.target, extra: sb.settings.target.extraConnectionString, timeoutSeconds: sb.settings.timeoutSeconds ?? '' } }); +const toView = sb => ({ ...sb, missing: !!sb.fileError, run: toRun(sb.run), settings: { ...sb.settings, ...sb.settings.target, extra: sb.settings.target.extraConnectionString, timeoutSeconds: sb.settings.timeoutSeconds ?? '' } }); // The server run (ISO times) in the prototype shape: times are seconds from the start of the run. // run.t is "now" on the server clock: the page clock plus the offset that the last state gave (the tests use a fake server clock). function toRun(r) { @@ -367,7 +367,7 @@ const c = counts(b); return c.Failed + c.Cancelled ? 'Failed' : 'Succeeded'; } function progressText(b) { - if (b.missing) return 'File not found'; + if (b.missing) return b.fileError; if (b.signing) return 'Signing in...'; if (!b.run) return 'Not run yet'; const c = counts(b), n = Object.keys(b.run.rs).length, bad = c.Failed + c.Cancelled; @@ -419,7 +419,7 @@ } function renderHeader(b) { if (!b) return '

No batch. Select New batch or Open.

'; - if (b.missing) return `

${esc(b.name)}

`; + if (b.missing) return `

${esc(b.name)}

`; const s = b.settings, c = counts(b), run = b.run, n = run ? Object.keys(run.rs).length : 0, live = run && !run.ended, bad = c.Failed + c.Cancelled; const seg = (k, v) => n ? `` : ''; return `

${esc(b.name)}${b.dirty ? ' •' : ''}

diff --git a/tests/SqlRunboard.Gui.Tests/RestartTests.cs b/tests/SqlRunboard.Gui.Tests/RestartTests.cs new file mode 100644 index 0000000..6cc0d4b --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/RestartTests.cs @@ -0,0 +1,60 @@ +using Microsoft.Playwright; +using SqlRunboard.Core; +using SqlRunboard.Gui; + +/// A restart: a second server on the same app data folder brings back the side list and the last runs. +public class RestartTests : AppPageTest +{ + readonly string dir = Directory.CreateTempSubdirectory("sqlrunboard-restart-").FullName; + + Batch Saved(string name, BatchTarget target) + { + var folder = Directory.CreateDirectory(Path.Combine(dir, name)).FullName; + File.WriteAllText(Path.Combine(folder, "1.sql"), "select 1"); + File.WriteAllText(Path.Combine(folder, "2.sql"), "select 1"); + var b = App.Host.Batches.NewBatch(); + App.Host.Batches.UpdateSettings(b.Id, new BatchSettings { Folder = folder, ParallelLimit = 1, Target = target }); + return App.Host.Batches.Save(b.Id, Path.Combine(folder, name), overwrite: false)!; + } + + [Fact] + public async Task The_side_list_and_the_last_runs_come_back_after_a_restart() + { + var target = new BatchTarget { Server = "sql01", Database = "Sales" }; + var gone = Saved("gone", target); + var deploy = Saved("deploy", target); + var changed = Saved("changed", target); + App.Host.Batches.NewBatch(); + App.Host.Batches.MoveBatch(deploy.Id, MoveTo.Top); + App.Executor.Scripts["2.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(30) }; + App.Host.Batches.Run(deploy.Id); + App.Host.Batches.Run(changed.Id); + await Expect(Page.Locator("[data-batch]")).ToHaveCountAsync(4); + File.Delete(gone.File!); + File.WriteAllText(changed.File!, File.ReadAllText(changed.File!).Replace("\"sql01\"", "\"sql02\"")); // changed while the app is stopped + + await using var again = await GuiHost.StartAsync(new GuiHostOptions(App.AppData, App.Executor, App.Time)); + var page = await Context.NewPageAsync(); + await OpenAsync(page, again.StartUrl); + + var side = page.GetByRole(AriaRole.Navigation, new() { Name = "Batches" }).GetByRole(AriaRole.Option); + await Expect(side).ToHaveCountAsync(3); // the unsaved batch does not come back + await Expect(side.Nth(0)).ToHaveAttributeAsync("aria-label", new System.Text.RegularExpressions.Regex("^deploy, Failed")); + await Expect(side.Nth(1)).ToContainTextAsync("gone"); + await Expect(side.Nth(2)).ToContainTextAsync("changed"); + + await side.Nth(0).ClickAsync(); + await page.Locator("#tabs").GetByRole(AriaRole.Tab, new() { Name = "Run" }).ClickAsync(); + await Expect(page.Locator("#panel [data-path='2.sql']")).ToHaveAttributeAsync("aria-label", new System.Text.RegularExpressions.Regex("^2.sql, Cancelled, attempt 1")); + await Expect(page.Locator("#hdr").GetByRole(AriaRole.Button, new() { Name = "Rerun all failed" })).ToBeEnabledAsync(); + + await side.Nth(2).ClickAsync(); + await page.Locator("#tabs").GetByRole(AriaRole.Tab, new() { Name = "Run" }).ClickAsync(); + await Expect(page.Locator("#panel")).ToContainTextAsync("The settings changed. Run the batch again."); + + await side.Nth(1).ClickAsync(); + await Expect(page.Locator("#hdr")).ToContainTextAsync("File not found"); + await page.Locator("#hdr").GetByRole(AriaRole.Button, new() { Name = "Remove from list" }).ClickAsync(); + await Expect(side).ToHaveCountAsync(2); + } +} From c663c3b8b37fa536ccbf8d72ebe16477ab58e53d Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:19:44 +0200 Subject: [PATCH 64/96] feat(gui): use the native folder picker of the desktop shell for Browse --- src/SqlRunboard.Gui/wwwroot/index.html | 12 +++- .../SqlRunboard.Gui.Tests/ShellBridgeTests.cs | 68 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 tests/SqlRunboard.Gui.Tests/ShellBridgeTests.cs diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 51b8066..1f2411d 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -315,6 +315,16 @@ try { openDialog({ kind, b, name, resolve, ...await post('/api/folders', { path, files: kind === 'folder' ? null : '*' + EXT }) }); } catch { if (path) browse(b, null, kind, resolve); else { toast('The folder list is not available.'); resolve?.(false); } } } +// The desktop shell: NativeWebView injects invokeCSharpAction at page load. The page sends { id, cmd, ... } and the shell +// answers with shellReply(id, path), or null if you cancel. cmd: folder, open, saveAs (native dialogs), stopped (close the window). +const inShell = () => typeof window.invokeCSharpAction === 'function'; +const shellWaits = {}; let shellId = 0; +const shell = (cmd, args = {}) => new Promise(r => { const id = ++shellId; shellWaits[id] = r; invokeCSharpAction(JSON.stringify({ id, cmd, ...args })); }); +function shellReply(id, path) { const r = shellWaits[id]; delete shellWaits[id]; r?.(path); } +function pickFolder(b) { + if (!inShell()) return browse(b, b.settings.folder); + shell('folder', { path: b.settings.folder }).then(p => { if (p) { b.settings.folder = p; saveSettings(b); render(); } }); +} // Batch files. The server reads and writes them; the page sends full paths. const EXT = '.sqlrunboard.json'; const isAbs = p => /^([a-zA-Z]:[\\/]|[\\/])/.test(p ?? ''); @@ -748,7 +758,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} case 'closeDrawer': b.drawer = false; render(); return document.querySelector(`[data-k="s:${CSS.escape(b.sel)}"]`)?.focus(); case 'runlog': return todo(); case 'test': return logon(b, r => { b.testMsg = `Connected. Server ${r.server}, database ${r.database}, login ${r.login}.`; }); - case 'browse': return browse(b, b.settings.folder); + case 'browse': return pickFolder(b); case 'sortFiles': return sendEdit(`/api/batches/${b.id}/sort`, {}); } } diff --git a/tests/SqlRunboard.Gui.Tests/ShellBridgeTests.cs b/tests/SqlRunboard.Gui.Tests/ShellBridgeTests.cs new file mode 100644 index 0000000..96f4250 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/ShellBridgeTests.cs @@ -0,0 +1,68 @@ +using System.Text.Json; +using Microsoft.Playwright; + +/// +/// The page in the desktop shell. NativeWebView injects invokeCSharpAction; here a fake bridge records the requests, +/// and the test answers them as the shell does, with shellReply(id, path). Playwright does not test the shell itself. +/// +public class ShellBridgeTests : AppPageTest +{ + readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-shell-").FullName; + + ILocator Panel => Page.Locator("#panel"); + ILocator Dialog => Page.GetByRole(AriaRole.Dialog); + ILocator Top(string name) => Page.GetByRole(AriaRole.Toolbar, new() { Name = "App" }).GetByRole(AriaRole.Button, new() { Name = name }); + + public override async ValueTask InitializeAsync() + { + await base.InitializeAsync(); + await Page.AddInitScriptAsync("window.sent = []; window.invokeCSharpAction = data => sent.push(JSON.parse(data));"); + await OpenAsync(Page, App.Host.BaseUrl); + } + + /// Waits for the next request to the shell and returns it. + async Task RequestAsync(int n = 0) + { + await Page.WaitForFunctionAsync($"() => sent.length > {n}"); + return await Page.EvaluateAsync($"() => sent[{n}]"); + } + + Task ReplyAsync(JsonElement request, string? path) => Page.EvaluateAsync("([id, p]) => shellReply(id, p)", new object?[] { request.GetProperty("id").GetInt32(), path }); + + async Task NewBatchAsync() + { + await Page.Keyboard.PressAsync("ControlOrMeta+n"); + await Expect(Panel.GetByLabel("Folder", new() { Exact = true })).ToBeVisibleAsync(); + } + + [Fact] + public async Task Browse_uses_the_native_folder_picker_and_sets_the_real_path() + { + await NewBatchAsync(); + + await Panel.GetByRole(AriaRole.Button, new() { Name = "Browse" }).ClickAsync(); + var req = await RequestAsync(); + Assert.Equal("folder", req.GetProperty("cmd").GetString()); + await Expect(Dialog).ToHaveCountAsync(0); + await ReplyAsync(req, root); + + await Expect(Panel.GetByLabel("Folder", new() { Exact = true })).ToHaveValueAsync(root); + } + + [Fact] + public async Task A_cancelled_folder_picker_keeps_the_folder() + { + await NewBatchAsync(); + + await Panel.GetByRole(AriaRole.Button, new() { Name = "Browse" }).ClickAsync(); + await ReplyAsync(await RequestAsync(), null); + + await Expect(Panel.GetByLabel("Folder", new() { Exact = true })).ToHaveValueAsync(""); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + Directory.Delete(root, recursive: true); + } +} From 1437b5da23e461f83a987565714a19e744a737ca Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:25:34 +0200 Subject: [PATCH 65/96] fix(core): remove a nullability warning in the newest log folder lookup --- src/SqlRunboard.Core/RunLog.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SqlRunboard.Core/RunLog.cs b/src/SqlRunboard.Core/RunLog.cs index b41b9cb..49ec48c 100644 --- a/src/SqlRunboard.Core/RunLog.cs +++ b/src/SqlRunboard.Core/RunLog.cs @@ -43,7 +43,7 @@ public static RunLog Create(string parent, string batchName, TimeProvider time, var name = new Regex($@"^{Regex.Escape(batchName)}_\d{{8}}-\d{{6}}(-\d+)?$"); // ponytail: the newest by folder name (local start time); a run in the repeated hour at a DST change can sort wrong. return Directory.EnumerateDirectories(logs).Where(d => name.IsMatch(Path.GetFileName(d)) && File.Exists(Path.Combine(d, SummaryFile))) - .OrderBy(Path.GetFileName, FileScan.NaturalOrder).LastOrDefault(); + .OrderBy(d => Path.GetFileName(d), FileScan.NaturalOrder).LastOrDefault(); } /// From 907e936673afff01120464921f12e3f1c4cd950e Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:58:42 +0200 Subject: [PATCH 66/96] feat(gui): use the native Open and Save as dialogs and close the shell window after Quit --- src/SqlRunboard.Gui/wwwroot/index.html | 13 +++- .../SqlRunboard.Gui.Tests/ShellBridgeTests.cs | 59 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 1f2411d..539688a 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -335,8 +335,16 @@ e => { if (e.status === 409) openDialog({ kind: 'changed', b, path }); else toast(e.message); return false; }); } // resolve (optional) gets the result of the save, or null if you cancel the dialog. -const saveAs = (b, resolve) => b && !b.missing && browse(b, b.file ?? (isAbs(b.settings.folder) ? b.settings.folder : null), 'saveAs', resolve); -const openBatch = () => browse(cur(), cur()?.file ?? null, 'open'); +function saveAs(b, resolve) { + if (!b || b.missing) return; + const path = b.file ?? (isAbs(b.settings.folder) ? b.settings.folder : null); + if (!inShell()) return browse(b, path, 'saveAs', resolve); + shell('saveAs', { path, name: b.name + EXT }).then(p => { const saved = p ? saveBatch(b, p) : null; resolve?.(saved); }); // the native dialog asks before it overwrites +} +function openBatch() { + if (!inShell()) return browse(cur(), cur()?.file ?? null, 'open'); + shell('open', { path: cur()?.file ?? null }).then(p => { if (!p) return; if (p.toLowerCase().endsWith(EXT)) openFile(p); else toast(`Open accepts only ${EXT} files.`); }); +} async function openFile(path) { // the server gives the existing entry if the file is already in the list try { const b = upsert(await post('/api/batches/open', { path })); app.cur = b.id; closeDialog(); } catch (e) { toast(e.message); } } @@ -736,6 +744,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} try { await post('/api/quit'); } catch (e) { toast(e.message); return false; } es.close(); delete document.documentElement.dataset.connected; openDialog({ kind: 'stopped' }); + if (inShell()) shell('stopped'); // the shell closes the window, also when the server is in another process return true; } function selectScript(b, path, openDrawer) { b.sel = path; if (openDrawer) b.drawer = true; render(); } diff --git a/tests/SqlRunboard.Gui.Tests/ShellBridgeTests.cs b/tests/SqlRunboard.Gui.Tests/ShellBridgeTests.cs index 96f4250..efa07d0 100644 --- a/tests/SqlRunboard.Gui.Tests/ShellBridgeTests.cs +++ b/tests/SqlRunboard.Gui.Tests/ShellBridgeTests.cs @@ -60,6 +60,65 @@ public async Task A_cancelled_folder_picker_keeps_the_folder() await Expect(Panel.GetByLabel("Folder", new() { Exact = true })).ToHaveValueAsync(""); } + [Fact] + public async Task Open_uses_the_native_file_dialog_and_opens_the_batch_file() + { + var b = App.Host.Batches.NewBatch(); + var file = App.Host.Batches.Save(b.Id, Path.Combine(root, "deploy"), overwrite: false)!.File!; + App.Host.Batches.Remove(b.Id); + + await Top("Open…").ClickAsync(); + var req = await RequestAsync(); + Assert.Equal("open", req.GetProperty("cmd").GetString()); + await Expect(Dialog).ToHaveCountAsync(0); + await ReplyAsync(req, file); + + await Expect(Page.Locator("#hdr h1")).ToHaveTextAsync("deploy"); + } + + [Fact] + public async Task Open_accepts_only_batch_files() + { + var other = Path.Combine(root, "notes.json"); + File.WriteAllText(other, "{}"); + + await Top("Open…").ClickAsync(); + await ReplyAsync(await RequestAsync(), other); + + await Expect(Page.Locator("#toast")).ToContainTextAsync(".sqlrunboard.json"); + } + + [Fact] + public async Task Save_as_uses_the_native_file_dialog_and_saves_to_the_real_path() + { + await NewBatchAsync(); + + await Top("Save as…").ClickAsync(); + var req = await RequestAsync(); + Assert.Equal("saveAs", req.GetProperty("cmd").GetString()); + Assert.Equal("Untitled 1.sqlrunboard.json", req.GetProperty("name").GetString()); + await Expect(Dialog).ToHaveCountAsync(0); + await ReplyAsync(req, Path.Combine(root, "nightly.sqlrunboard.json")); + + await Expect(Page.Locator("#hdr h1")).ToHaveTextAsync("nightly"); + Assert.True(File.Exists(Path.Combine(root, "nightly.sqlrunboard.json"))); + } + + [Fact] + public async Task Quit_saves_a_never_saved_batch_with_the_native_dialog_then_tells_the_shell_to_close() + { + App.Host.Batches.NewBatch(); + await OpenAsync(Page, App.Host.BaseUrl); + + _ = Page.EvaluateAsync("() => { quit(); }"); // the shell calls quit() from the close event of the window + await Dialog.GetByRole(AriaRole.Button, new() { Name = "Save", Exact = true }).ClickAsync(); + await ReplyAsync(await RequestAsync(), Path.Combine(root, "nightly.sqlrunboard.json")); + + Assert.Equal("stopped", (await RequestAsync(1)).GetProperty("cmd").GetString()); + await Expect(Dialog).ToContainTextAsync("SQLRunboard stopped"); + Assert.True(File.Exists(Path.Combine(root, "nightly.sqlrunboard.json"))); + } + public override async ValueTask DisposeAsync() { await base.DisposeAsync(); From d7263a61678e23d72170e5cbd85a13e8e6761927 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:16:16 +0200 Subject: [PATCH 67/96] feat(desktop): answer the page's native dialog requests and quit through the page on window close --- src/sqlrunboard-desktop/Program.cs | 53 ++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/src/sqlrunboard-desktop/Program.cs b/src/sqlrunboard-desktop/Program.cs index ca84fdd..b98d965 100644 --- a/src/sqlrunboard-desktop/Program.cs +++ b/src/sqlrunboard-desktop/Program.cs @@ -3,9 +3,11 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.Presenters; using Avalonia.Controls.Templates; +using Avalonia.Platform.Storage; using Avalonia.Threading; using SqlRunboard.Core; using SqlRunboard.Gui; +using System.Text.Json; const string Usage = "Usage: sqlrunboard-desktop [--app-data ]"; @@ -30,17 +32,62 @@ AppBuilder.Configure().UsePlatformDetect().SetupWithLifetime(lifetime); // The page loads from 127.0.0.1 with the token, as in the browser, so all server checks stay in force. -lifetime.MainWindow = new Window +var webView = new NativeWebView { Source = startUrl }; +var window = new Window { Title = "SQLRunboard", Width = 1280, Height = 800, // No theme package: a bare template that shows the content. Template = new FuncControlTemplate((w, _) => new ContentPresenter { [!ContentPresenter.ContentProperty] = w[!ContentControl.ContentProperty] }), - Content = new NativeWebView { Source = startUrl }, + Content = webView, }; +lifetime.MainWindow = window; + +// The close event does what Quit in the page does. The page asks to save and to confirm; when the app has stopped, +// it sends "stopped" (or the server stop below closes the window). Without a page or a server, the window closes at once. +var stopped = false; +void CloseNow() { stopped = true; lifetime.Shutdown(); } +window.Closing += async (_, e) => +{ + if (stopped) return; + e.Cancel = true; + // Reuse path: if the other process's server is gone, the page cannot quit, so do not ask. + var noServer = host is null ? "!('connected' in document.documentElement.dataset)" : "false"; + try { if ((await webView.InvokeScript($"typeof quit !== 'function' || {noServer} ? 'none' : (quit(), 'asked')"))?.Contains("asked") == true) return; } + catch (Exception) { } // no page loaded + CloseNow(); +}; + +// The page asks for the native dialogs: { id, cmd, path, name }. The answer is shellReply(id, real path or null). +var batchFiles = new FilePickerFileType("SQLRunboard batch") { Patterns = ["*.sqlrunboard.json"] }; +webView.WebMessageReceived += async (_, e) => +{ + using var json = JsonDocument.Parse(e.Body ?? "{}"); + var m = json.RootElement; + var cmd = m.GetProperty("cmd").GetString(); + if (cmd == "stopped") { CloseNow(); return; } + string? Text(string name) => m.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + string? result = null; + try + { + var sp = window.StorageProvider; + var path = Text("path"); + var dir = path is null || !Path.IsPathRooted(path) ? null : Directory.Exists(path) ? path : Path.GetDirectoryName(path); + var start = dir is null ? null : await sp.TryGetFolderFromPathAsync(dir); + result = cmd switch + { + "folder" => (await sp.OpenFolderPickerAsync(new() { SuggestedStartLocation = start })).FirstOrDefault()?.TryGetLocalPath(), + "open" => (await sp.OpenFilePickerAsync(new() { SuggestedStartLocation = start, FileTypeFilter = [batchFiles] })).FirstOrDefault()?.TryGetLocalPath(), + "saveAs" => (await sp.SaveFilePickerAsync(new() { SuggestedStartLocation = start, SuggestedFileName = Text("name"), FileTypeChoices = [batchFiles] }))?.TryGetLocalPath(), + _ => null, + }; + } + finally { await webView.InvokeScript($"shellReply({m.GetProperty("id").GetInt32()}, {JsonSerializer.Serialize(result)})"); } +}; + // Quit in the page, Ctrl+C and SIGTERM stop the server: then the window closes too. -host?.WaitForShutdownAsync().ContinueWith(_ => Dispatcher.UIThread.Post(() => lifetime.Shutdown()), TaskScheduler.Default); +host?.WaitForShutdownAsync().ContinueWith(_ => Dispatcher.UIThread.Post(CloseNow), TaskScheduler.Default); var code = lifetime.Start(args); if (host is not null) host.DisposeAsync().AsTask().GetAwaiter().GetResult(); return code; From c7a712a88adaef3c42970a80370ef2667ab016b6 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:18:21 +0200 Subject: [PATCH 68/96] fix(gui): open the card menu on the chart of a Queued timeline row before the first run --- src/SqlRunboard.Gui/wwwroot/index.html | 2 +- tests/SqlRunboard.Gui.Tests/TimelineTests.cs | 53 ++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/SqlRunboard.Gui.Tests/TimelineTests.cs diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 51e0c39..e91778e 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -511,7 +511,7 @@
${lastA(x.r)?.start != null ? clock(run, lastA(x.r).start) : ''}
${elapsedOf(b, x.r) != null ? dur(elapsedOf(b, x.r)) : ''}
${Math.max(1, x.r.attempts.length)}
` : `
${bubble(x.r.status)}${esc(x.path)}
`; const lead = cols ? '
#
Status
Script
Start
Elapsed
Att.
' : '
'; - if (!run) return `

No run yet

${lead}
${rows.map(x => names(x) + '
').join('')}
`; + if (!run) return `

No run yet

${lead}
${rows.map(x => `${names(x)}
`).join('')}
`; const sc = tlScale(b), limit = +b.settings.parallelLimit || 1; const pts = []; for (let i = 0; i <= 300; i++) { diff --git a/tests/SqlRunboard.Gui.Tests/TimelineTests.cs b/tests/SqlRunboard.Gui.Tests/TimelineTests.cs new file mode 100644 index 0000000..fd5b3cd --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/TimelineTests.cs @@ -0,0 +1,53 @@ +using Microsoft.Playwright; +using SqlRunboard.Core; + +public class TimelineTests : AppPageTest +{ + readonly string scripts = Directory.CreateTempSubdirectory("sqlrunboard-scripts-").FullName; + + // A fixed zone for the local times: the fake clock starts 2026-10-09T12:00Z = 14:00 in Berlin (summer time). + public override BrowserNewContextOptions ContextOptions() => new() { TimezoneId = "Europe/Berlin" }; + + ILocator Panel => Page.Locator("#panel"); + ILocator Side(string name) => Page.GetByRole(AriaRole.Navigation, new() { Name = "Batches" }).Locator($"[role=option]:has-text('{name}')"); + ILocator Names => Panel.Locator(".tlg > .c[data-k] .tnum"); + ILocator Row(string path) => Panel.Locator($".tlg > .chart[data-path='{path}']"); + ILocator Bars(string path) => Row(path).Locator(".bar"); + ILocator Menu(string path) => Page.GetByRole(AriaRole.Menu, new() { Name = path }); + + // The batch events can come after the click on the side list, so open the Run tab by hand. + async Task OpenTimeline(string name) + { + await Side(name).ClickAsync(); + await Page.GetByRole(AriaRole.Tab, new() { Name = "Run" }).ClickAsync(); + await Panel.GetByRole(AriaRole.Group, new() { Name = "View" }).GetByRole(AriaRole.Button, new() { Name = "Timeline" }).ClickAsync(); + } + + Batch NewBatch(int limit, params string[] files) + { + foreach (var f in files) File.WriteAllText(Path.Combine(scripts, f), "select 1"); + var b = App.Host.Batches.NewBatch(); + return App.Host.Batches.UpdateSettings(b.Id, new BatchSettings { Folder = scripts, ParallelLimit = limit })!; + } + + [Fact] + public async Task Before_the_first_run_the_timeline_shows_the_Queued_rows_and_No_run_yet() + { + var b = NewBatch(1, "a.sql", "b.sql", "c.sql"); + await OpenTimeline(b.Name); + + await Expect(Panel).ToContainTextAsync("No run yet"); + await Expect(Names).ToHaveTextAsync(["a.sql", "b.sql", "c.sql"]); + await Expect(Panel.Locator(".bar")).ToHaveCountAsync(0); + + await Row("c.sql").ClickAsync(new() { Button = MouseButton.Right }); + await Menu("c.sql").GetByRole(AriaRole.Menuitem, new() { Name = "Move to top" }).ClickAsync(); + await Expect(Names).ToHaveTextAsync(["c.sql", "a.sql", "b.sql"]); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + Directory.Delete(scripts, recursive: true); + } +} From 8de54b32b234daca726ebb3d4f9d5f7419281823 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:52:10 +0200 Subject: [PATCH 69/96] fix(gui): draw the timeline concurrency strip from exact attempt starts and ends --- src/SqlRunboard.Gui/wwwroot/index.html | 14 +++--- tests/SqlRunboard.Gui.Tests/TimelineTests.cs | 48 ++++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index e91778e..0401b1a 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -513,13 +513,13 @@ const lead = cols ? '
#
Status
Script
Start
Elapsed
Att.
' : '
'; if (!run) return `

No run yet

${lead}
${rows.map(x => `${names(x)}
`).join('')}
`; const sc = tlScale(b), limit = +b.settings.parallelLimit || 1; - const pts = []; - for (let i = 0; i <= 300; i++) { - const p = i / 300, t = sc.inv(p); - pts.push([p, t == null ? 0 : Object.values(run.rs).reduce((n, r) => n + r.attempts.filter(a => a.start != null && a.start <= t && (aEnd(run, a) ?? a.start) > t).length, 0)]); - } - const top = Math.max(limit, ...pts.map(q => q[1])) + 1; - const path = pts.map(([x, y], i) => i ? `H${x}V${top - y}` : `M0 ${top - y}`).join(''); + // concurrency steps: +1 at each start, -1 at each end (a Running attempt has no end yet); one step per time + const ev = Object.values(run.rs).flatMap(r => r.attempts).filter(a => a.start != null) + .flatMap(a => { const e = a.status === 'Running' ? null : aEnd(run, a) ?? a.start; return e == null ? [[a.start, 1]] : [[a.start, 1], [e, -1]]; }).sort((p, q) => p[0] - q[0]); + const steps = []; + for (let i = 0, n = 0; i < ev.length; i++) { n += ev[i][1]; if (ev[i + 1]?.[0] !== ev[i][0] && n !== (steps[steps.length - 1]?.[1] ?? 0)) steps.push([sc.f(ev[i][0]), n]); } + const top = Math.max(limit, ...steps.map(q => q[1])) + 1; + const path = `M0 ${top}${steps.map(([x, n]) => `H${x}V${top - n}`).join('')}H1`; const ticks = [0, .25, .5, .75, 1].map(p => { const t = sc.inv(p); return `${t == null ? '' : dur(t)}`; }).join(''); const breaks = sc.gaps.map(g => `
`).join(''); const breakLabels = sc.gaps.map(g => `
⋯ ${dur(g.e - g.s)} idle
`).join(''); diff --git a/tests/SqlRunboard.Gui.Tests/TimelineTests.cs b/tests/SqlRunboard.Gui.Tests/TimelineTests.cs index fd5b3cd..3e41511 100644 --- a/tests/SqlRunboard.Gui.Tests/TimelineTests.cs +++ b/tests/SqlRunboard.Gui.Tests/TimelineTests.cs @@ -45,6 +45,54 @@ public async Task Before_the_first_run_the_timeline_shows_the_Queued_rows_and_No await Expect(Names).ToHaveTextAsync(["c.sql", "a.sql", "b.sql"]); } + [Fact] + public async Task A_live_run_shows_a_bar_for_each_attempt_in_the_order_of_the_first_start() + { + App.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(10), Rows = 5 }; + App.Executor.Scripts["b.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(60) }; + App.Executor.Scripts["c.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(10), Status = ScriptStatus.Failed, Error = "boom" }; + App.Executor.Scripts["d.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(60) }; + App.Executor.Scripts["e.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(60) }; + App.Executor.Scripts["f.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(60) }; + var b = NewBatch(2, "a.sql", "b.sql", "c.sql", "d.sql", "e.sql", "f.sql"); + App.Host.Batches.Run(b.Id); + await OpenTimeline(b.Name); + + await Expect(Panel).ToContainTextAsync("Run started 14:00:00"); + await Expect(Bars("a.sql")).ToHaveCountAsync(1); + App.Time.Advance(TimeSpan.FromSeconds(10)); // a ends, c starts + await Expect(Bars("c.sql")).ToHaveCountAsync(1); + App.Time.Advance(TimeSpan.FromSeconds(10)); // c fails, d starts + await Expect(Bars("d.sql")).ToHaveCountAsync(1); + + await Expect(Names).ToHaveTextAsync(["a.sql", "b.sql", "c.sql", "d.sql", "e.sql", "f.sql"]); + await Expect(Panel.Locator(".bar")).ToHaveCountAsync(4); // e and f are Queued: rows with no bar + await Expect(Bars("a.sql").Locator(".i")).ToHaveTextAsync("✓"); + await Expect(Bars("a.sql").Locator(".d")).ToHaveTextAsync("0:10"); + await Expect(Bars("a.sql")).ToHaveAttributeAsync("style", new System.Text.RegularExpressions.Regex("--green")); + await Expect(Bars("b.sql").Locator(".i")).ToHaveTextAsync("▶"); + await Expect(Bars("b.sql")).ToHaveAttributeAsync("style", new System.Text.RegularExpressions.Regex("--yellow")); + await Expect(Bars("c.sql").Locator(".i")).ToHaveTextAsync("✕"); + await Expect(Bars("c.sql")).ToHaveAttributeAsync("style", new System.Text.RegularExpressions.Regex("--red")); + + // the concurrency strip: 2 running scripts all the time, and a dashed line at the parallel limit 2 + var strip = Panel.Locator("svg.strip"); + await Expect(strip.Locator("line[stroke-dasharray]")).ToHaveAttributeAsync("y1", "1"); // top = limit + 1 = 3 + await Expect(strip.Locator("path").Last).ToHaveAttributeAsync("d", "M0 3H0V1H1"); // 2 from the start to now: a end and c start at the same time + + // the move commands work on the Queued rows + await Row("f.sql").ClickAsync(new() { Button = MouseButton.Right }); + await Menu("f.sql").GetByRole(AriaRole.Menuitem, new() { Name = "Move to top" }).ClickAsync(); + await Expect(Names).ToHaveTextAsync(["a.sql", "b.sql", "c.sql", "d.sql", "f.sql", "e.sql"]); + + // a cancelled bar is red with a hatch + App.Host.Batches.CancelAll(b.Id); + await Expect(Bars("b.sql")).ToHaveClassAsync(new System.Text.RegularExpressions.Regex(@"\bhatch\b")); + await Expect(Bars("b.sql")).ToHaveAttributeAsync("style", new System.Text.RegularExpressions.Regex("--red")); + await Expect(Bars("b.sql").Locator(".i")).ToHaveTextAsync("✕"); + await Expect(Bars("c.sql")).Not.ToHaveClassAsync(new System.Text.RegularExpressions.Regex(@"\bhatch\b")); + } + public override async ValueTask DisposeAsync() { await base.DisposeAsync(); From 21b060daada255657a8adc8698dd44cd3281f1e5 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:09:56 +0200 Subject: [PATCH 70/96] fix(gui): keep the minimum timeline bar width at 2 px --- src/SqlRunboard.Gui/wwwroot/index.html | 3 +- tests/SqlRunboard.Gui.Tests/TimelineTests.cs | 34 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 0401b1a..73a1e19 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -101,7 +101,8 @@ .tlg > .c { padding: 0 6px; height: 26px; display: flex; align-items: center; gap: 6px; border-bottom: 1px dashed var(--line); white-space: nowrap; overflow: hidden; cursor: pointer; } .tlg > .h { padding: 0 6px; color: var(--muted); font-weight: 600; font-size: 12px; align-self: end; } .chart { position: relative; height: 26px; border-bottom: 1px dashed var(--line); cursor: pointer; } -.bar { position: absolute; top: 4px; height: 18px; min-width: 2px; border-radius: 3px; color: var(--on); font-size: 11px; line-height: 18px; padding: 0 3px; overflow: hidden; white-space: nowrap; text-align: right; container-type: inline-size; } +.bar { position: absolute; top: 4px; height: 18px; min-width: 2px; border-radius: 3px; color: var(--on); font-size: 11px; line-height: 18px; overflow: hidden; white-space: nowrap; text-align: right; container-type: inline-size; } +.bar .i { padding-right: 3px; } /* padding on the bar would make a short bar wider than 2 px */ @container (max-width: 62px) { .bar .d { display: none; } } @container (max-width: 12px) { .bar .i { display: none; } } .brk { position: absolute; top: 0; bottom: 0; background: repeating-linear-gradient(90deg, var(--line) 0 2px, transparent 2px 5px); opacity: .7; } diff --git a/tests/SqlRunboard.Gui.Tests/TimelineTests.cs b/tests/SqlRunboard.Gui.Tests/TimelineTests.cs index 3e41511..d9841c6 100644 --- a/tests/SqlRunboard.Gui.Tests/TimelineTests.cs +++ b/tests/SqlRunboard.Gui.Tests/TimelineTests.cs @@ -93,6 +93,40 @@ public async Task A_live_run_shows_a_bar_for_each_attempt_in_the_order_of_the_fi await Expect(Bars("c.sql")).Not.ToHaveClassAsync(new System.Text.RegularExpressions.Regex(@"\bhatch\b")); } + [Fact] + public async Task The_chart_fits_the_run_to_the_width_and_a_rerun_makes_the_axis_longer() + { + App.Executor.Scripts["b.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(100) }; + App.Executor.Scripts["c.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(40) }; + var b = NewBatch(2, "a.sql", "b.sql", "c.sql"); // a.sql ends at once + App.Host.Batches.Run(b.Id); + await OpenTimeline(b.Name); + var lastTick = Panel.Locator(".axis span").Last; + + await Expect(Bars("c.sql")).ToHaveCountAsync(1); + App.Time.Advance(TimeSpan.FromSeconds(40)); // c ends + await Expect(Bars("c.sql").Locator(".i")).ToHaveTextAsync("✓"); + await Expect(Bars("b.sql").Locator(".d")).ToHaveTextAsync(new System.Text.RegularExpressions.Regex("^0:4")); // the Running bar grows live + App.Time.Advance(TimeSpan.FromSeconds(60)); // b ends: the run is complete + await Expect(Bars("b.sql").Locator(".i")).ToHaveTextAsync("✓"); + + await Expect(Panel.Locator(".axis span").First).ToHaveTextAsync("0:00"); + await Expect(lastTick).ToHaveTextAsync("1:40"); + var chart = (await Row("b.sql").BoundingBoxAsync())!; + var bar = (await Bars("b.sql").BoundingBoxAsync())!; + Assert.InRange(bar.X + bar.Width, chart.X + chart.Width - 1, chart.X + chart.Width + 1); // the full run fits the width + Assert.InRange((await Bars("a.sql").BoundingBoxAsync())!.Width, 2, 2.5); // 0 s: the minimum width + await Expect(Bars("b.sql")).ToHaveAttributeAsync("title", new System.Text.RegularExpressions.Regex("Start 14:00:00\nEnd 14:01:40\nDuration 1:40$")); + + await Row("c.sql").ClickAsync(new() { Button = MouseButton.Right }); + await Menu("c.sql").GetByRole(AriaRole.Menuitem, new() { Name = "Rerun" }).ClickAsync(); + await Expect(Bars("c.sql")).ToHaveCountAsync(2); // the rerun stays on the row of its script + await Expect(Bars("c.sql").Last.Locator(".i")).ToHaveTextAsync("▶"); + App.Time.Advance(TimeSpan.FromSeconds(40)); + await Expect(Bars("c.sql").Last.Locator(".i")).ToHaveTextAsync("✓"); + await Expect(lastTick).ToHaveTextAsync("2:20"); + } + public override async ValueTask DisposeAsync() { await base.DisposeAsync(); From b05fe4dcdb6429827f6863da5962a99ddf6d2895 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:19:10 +0200 Subject: [PATCH 71/96] test(gui): cover the timeline bar click, the drawer scroll and the shared selection --- tests/SqlRunboard.Gui.Tests/TimelineTests.cs | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/SqlRunboard.Gui.Tests/TimelineTests.cs b/tests/SqlRunboard.Gui.Tests/TimelineTests.cs index d9841c6..1d6c765 100644 --- a/tests/SqlRunboard.Gui.Tests/TimelineTests.cs +++ b/tests/SqlRunboard.Gui.Tests/TimelineTests.cs @@ -127,6 +127,33 @@ public async Task The_chart_fits_the_run_to_the_width_and_a_rerun_makes_the_axis await Expect(lastTick).ToHaveTextAsync("2:20"); } + [Fact] + public async Task A_click_on_a_bar_opens_the_drawer_at_that_attempt_with_the_selection_of_the_board() + { + App.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(10), Status = ScriptStatus.Failed, Error = "boom", Messages = [.. Enumerable.Range(1, 100).Select(i => new SqlMessage(SqlMessageKind.Info, $"m{i}"))] }; + var b = NewBatch(1, "a.sql", "b.sql"); + App.Host.Batches.Run(b.Id); + await OpenTimeline(b.Name); + App.Time.Advance(TimeSpan.FromSeconds(10)); + await Expect(Bars("a.sql").Locator(".i")).ToHaveTextAsync("✕"); + App.Host.Batches.Rerun(b.Id, "a.sql"); + await Expect(Bars("a.sql")).ToHaveCountAsync(2); + App.Time.Advance(TimeSpan.FromSeconds(10)); + await Expect(Bars("a.sql").Last.Locator(".i")).ToHaveTextAsync("✕"); + + var drawer = Page.Locator("#drawer"); + await Bars("a.sql").First.ClickAsync(); + await Expect(drawer.Locator(".dh .t")).ToHaveTextAsync("✕a.sql"); + await Expect(drawer.Locator("#att-1")).ToBeInViewportAsync(); // the drawer would show the end of attempt 2 without the scroll + await Expect(drawer.Locator("#att-2")).Not.ToBeInViewportAsync(); + await Expect(Panel.Locator(".tlg > .c.issel .tnum")).ToHaveTextAsync("a.sql"); + + await Row("b.sql").ClickAsync(); + await Expect(drawer.Locator(".dh .t")).ToHaveTextAsync("✓b.sql"); + await Panel.GetByRole(AriaRole.Group, new() { Name = "View" }).GetByRole(AriaRole.Button, new() { Name = "Board" }).ClickAsync(); + await Expect(Panel.Locator(".card.issel")).ToContainTextAsync("b.sql"); + } + public override async ValueTask DisposeAsync() { await base.DisposeAsync(); From 0756e117d837bbb1cfceea8ae6f39379ed708aba Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:38:00 +0200 Subject: [PATCH 72/96] fix(gui): end an unfinished attempt of an ended run in the timeline concurrency strip --- src/SqlRunboard.Gui/wwwroot/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index e13ac5e..bdc666d 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -534,7 +534,7 @@ const sc = tlScale(b), limit = +b.settings.parallelLimit || 1; // concurrency steps: +1 at each start, -1 at each end (a Running attempt has no end yet); one step per time const ev = Object.values(run.rs).flatMap(r => r.attempts).filter(a => a.start != null) - .flatMap(a => { const e = a.status === 'Running' ? null : aEnd(run, a) ?? a.start; return e == null ? [[a.start, 1]] : [[a.start, 1], [e, -1]]; }).sort((p, q) => p[0] - q[0]); + .flatMap(a => { const e = a.status === 'Running' && !run.ended ? null : aEnd(run, a) ?? a.start; return e == null ? [[a.start, 1]] : [[a.start, 1], [e, -1]]; }).sort((p, q) => p[0] - q[0]); const steps = []; for (let i = 0, n = 0; i < ev.length; i++) { n += ev[i][1]; if (ev[i + 1]?.[0] !== ev[i][0] && n !== (steps[steps.length - 1]?.[1] ?? 0)) steps.push([sc.f(ev[i][0]), n]); } const top = Math.max(limit, ...steps.map(q => q[1])) + 1; From da7ce87d28c5d593f85a15c7356ea5a1ab830742 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:41:05 +0200 Subject: [PATCH 73/96] test(gui): cover the timeline Columns and Collapse idle gaps toggles and their stored state --- tests/SqlRunboard.Gui.Tests/TimelineTests.cs | 107 +++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/tests/SqlRunboard.Gui.Tests/TimelineTests.cs b/tests/SqlRunboard.Gui.Tests/TimelineTests.cs index 1d6c765..707779a 100644 --- a/tests/SqlRunboard.Gui.Tests/TimelineTests.cs +++ b/tests/SqlRunboard.Gui.Tests/TimelineTests.cs @@ -154,6 +154,113 @@ public async Task A_click_on_a_bar_opens_the_drawer_at_that_attempt_with_the_sel await Expect(Panel.Locator(".card.issel")).ToContainTextAsync("b.sql"); } + ILocator Toggle(string name) => Panel.GetByRole(AriaRole.Checkbox, new() { Name = name }); + + [Fact] + public async Task Columns_shows_a_table_with_a_Gantt_column_and_the_strip_and_axis_in_its_header() + { + App.Executor.Scripts["b.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(40) }; + var b = NewBatch(1, "a.sql", "b.sql", "c.sql"); // a.sql ends at once + App.Host.Batches.Run(b.Id); + await OpenTimeline(b.Name); + await Expect(Bars("b.sql")).ToHaveCountAsync(1); + + // off (default): the script names and the chart + await Expect(Toggle("Columns")).Not.ToBeCheckedAsync(); + await Expect(Panel.Locator(".tlg > .c[data-path='a.sql']")).ToHaveTextAsync(["✓a.sql"]); + + await Toggle("Columns").CheckAsync(); + await Expect(Panel.Locator(".tlg > .h")).ToHaveTextAsync(["#", "Status", "Script", "Start", "Elapsed", "Att."]); + await Expect(Panel.Locator(".tlg > .c[data-path='a.sql']")).ToHaveTextAsync(["", "✓ Succeeded", "a.sql", "14:00:00", "0:00", "1"]); + await Expect(Panel.Locator(".tlg > .c[data-path='c.sql']")).ToHaveTextAsync(["1", "○ Queued", "c.sql", "", "", "1"]); + await Expect(Bars("b.sql")).ToHaveCountAsync(1); // the Gantt column + + // the strip and the axis are in the header of the chart column + var head = Panel.Locator(".tlg > .striphead"); + await Expect(head.Locator("svg.strip")).ToHaveCountAsync(1); + await Expect(head.Locator(".axis span").First).ToHaveTextAsync("0:00"); + var h = (await head.BoundingBoxAsync())!; + var chart = (await Row("a.sql").BoundingBoxAsync())!; + Assert.InRange(h.X, chart.X - 1, chart.X + 1); + Assert.InRange(h.Width, chart.Width - 1, chart.Width + 1); + Assert.True(h.Y + h.Height <= chart.Y + 1); + + await Toggle("Columns").UncheckAsync(); + await Expect(Panel.Locator(".tlg > .h")).ToHaveCountAsync(1); + } + + static readonly System.Text.RegularExpressions.Regex Num = new(@"H([\d.e-]+)"); + + async Task StripXs() => [.. Num.Matches(await Panel.Locator("svg.strip path").Last.GetAttributeAsync("d") ?? "").Select(m => double.Parse(m.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture))]; + + static void Near(double[] expected, double[] actual) + { + Assert.Equal(expected.Length, actual.Length); + for (var i = 0; i < expected.Length; i++) Assert.InRange(actual[i], expected[i] - 0.001, expected[i] + 0.001); + } + + [Fact] + public async Task Collapse_idle_gaps_cuts_each_gap_over_1_minute_in_the_bars_and_the_strip() + { + App.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(10) }; + var b = NewBatch(1, "a.sql"); + App.Host.Batches.Run(b.Id); + await OpenTimeline(b.Name); + var icons = Bars("a.sql").Locator(".i"); + await Expect(icons).ToHaveTextAsync(["▶"]); + App.Time.Advance(TimeSpan.FromSeconds(10)); // 0:00-0:10 + await Expect(icons).ToHaveTextAsync(["✓"]); + App.Time.Advance(TimeSpan.FromSeconds(120)); // idle 2:00: cut + App.Host.Batches.Rerun(b.Id, "a.sql"); + await Expect(icons).ToHaveTextAsync(["✓", "▶"]); + App.Time.Advance(TimeSpan.FromSeconds(10)); // 2:10-2:20 + await Expect(icons).ToHaveTextAsync(["✓", "✓"]); + App.Time.Advance(TimeSpan.FromSeconds(60)); // idle 1:00: not cut + App.Host.Batches.Rerun(b.Id, "a.sql"); + await Expect(icons).ToHaveTextAsync(["✓", "✓", "▶"]); + App.Time.Advance(TimeSpan.FromSeconds(10)); // 3:20-3:30 + await Expect(icons).ToHaveTextAsync(["✓", "✓", "✓"]); + + // off (default): the full time to scale, 210 s + await Expect(Toggle("Collapse idle gaps")).Not.ToBeCheckedAsync(); + await Expect(Panel.Locator(".axis span").Last).ToHaveTextAsync("3:30"); + await Expect(Panel.Locator(".brkl")).ToHaveCountAsync(0); + Near([0, 10 / 210.0, 130 / 210.0, 140 / 210.0, 200 / 210.0, 1, 1], await StripXs()); + + // on: the 2:00 gap shrinks to a break of 5 % of the width; kept time 90 s, D = 90 + 90 * 0.05 / 0.95 + await Toggle("Collapse idle gaps").CheckAsync(); + await Expect(Panel.Locator(".brkl")).ToHaveTextAsync(["⋯ 2:00 idle"]); + await Expect(Row("a.sql").Locator(".brk")).ToHaveCountAsync(1); + var d = 90 + 4.5 / 0.95; + double[] cut = [0, 10 / d, (10 + 4.5 / 0.95) / d, (20 + 4.5 / 0.95) / d, (80 + 4.5 / 0.95) / d, 1, 1]; + Near(cut, await StripXs()); // the strip uses the same breaks + var lefts = await Bars("a.sql").EvaluateAllAsync("els => els.map(e => parseFloat(e.style.left) / 100)"); + Near([cut[0], cut[2], cut[4]], lefts); + + await Toggle("Collapse idle gaps").UncheckAsync(); + await Expect(Panel.Locator(".brkl")).ToHaveCountAsync(0); + } + + [Fact] + public async Task The_app_settings_keep_both_toggles() + { + var b = NewBatch(1, "a.sql"); + App.Host.Batches.Run(b.Id); + await OpenTimeline(b.Name); + await Page.RunAndWaitForResponseAsync(() => Toggle("Columns").CheckAsync(), r => r.Url.EndsWith("/api/app-settings")); + await Page.RunAndWaitForResponseAsync(() => Toggle("Collapse idle gaps").CheckAsync(), r => r.Url.EndsWith("/api/app-settings")); + + await using var context = await Browser.NewContextAsync(); // a new browser profile + var page = await context.NewPageAsync(); + await OpenAsync(page, App.Host.StartUrl); + await page.Locator($"[role=option]:has-text('{b.Name}')").ClickAsync(); + await page.GetByRole(AriaRole.Tab, new() { Name = "Run" }).ClickAsync(); + var panel = page.Locator("#panel"); + await panel.GetByRole(AriaRole.Group, new() { Name = "View" }).GetByRole(AriaRole.Button, new() { Name = "Timeline" }).ClickAsync(); + await Expect(panel.GetByRole(AriaRole.Checkbox, new() { Name = "Columns" })).ToBeCheckedAsync(); + await Expect(panel.GetByRole(AriaRole.Checkbox, new() { Name = "Collapse idle gaps" })).ToBeCheckedAsync(); + } + public override async ValueTask DisposeAsync() { await base.DisposeAsync(); From 5ab29215051a9b0bfa683fcd735eccdd5ee4dac0 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:58:13 +0200 Subject: [PATCH 74/96] test(core): stop the runs before a test deletes its temp folder --- tests/SqlRunboard.Core.Tests/LogonTests.cs | 9 +++++++-- tests/SqlRunboard.Core.Tests/MessageTests.cs | 6 ++++-- tests/SqlRunboard.Core.Tests/RestoreTests.cs | 9 +++++++-- tests/SqlRunboard.Core.Tests/RunLogTests.cs | 8 +++++--- tests/SqlRunboard.Core.Tests/RunTests.cs | 6 ++++-- tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs | 9 +++++++-- 6 files changed, 34 insertions(+), 13 deletions(-) diff --git a/tests/SqlRunboard.Core.Tests/LogonTests.cs b/tests/SqlRunboard.Core.Tests/LogonTests.cs index 33caa82..6e3d77a 100644 --- a/tests/SqlRunboard.Core.Tests/LogonTests.cs +++ b/tests/SqlRunboard.Core.Tests/LogonTests.cs @@ -1,7 +1,7 @@ using SqlRunboard.Core; /// The password of a batch: the server keeps it in memory after a logon, and forgets it after a logon failure or a target change. -public sealed class LogonTests : IDisposable +public sealed class LogonTests : IAsyncDisposable { readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-logon-").FullName; readonly FakeTime time = new(); @@ -104,5 +104,10 @@ public async Task An_integrated_logon_needs_no_password() [Fact] public async Task An_unknown_batch_has_no_logon() => Assert.Null(await list.LogonAsync(99, "x", ct)); - public void Dispose() => Directory.Delete(root, recursive: true); + /// Stops the runs first: a late script end writes its log in root. + public async ValueTask DisposeAsync() + { + await list.StopAsync(); + Directory.Delete(root, recursive: true); + } } diff --git a/tests/SqlRunboard.Core.Tests/MessageTests.cs b/tests/SqlRunboard.Core.Tests/MessageTests.cs index b5c9613..fac5fc5 100644 --- a/tests/SqlRunboard.Core.Tests/MessageTests.cs +++ b/tests/SqlRunboard.Core.Tests/MessageTests.cs @@ -1,7 +1,7 @@ using SqlRunboard.Core; /// The messages of each attempt, for the drawer: live "message" events, and the kept messages in a new subscription. -public sealed class MessageTests : IDisposable +public sealed class MessageTests : IAsyncDisposable { readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-msg-").FullName; readonly FakeTime time = new(); @@ -98,9 +98,11 @@ public async Task Errors_of_the_engine_are_messages_of_their_attempt_too() Assert.All(late.Messages, m => Assert.Equal(SqlMessageKind.Error, m.Messages[0].Kind)); } - public void Dispose() + /// Stops the runs first: a late script end writes its log in root. + public async ValueTask DisposeAsync() { sub.Dispose(); + await list.StopAsync(); Directory.Delete(root, recursive: true); } } diff --git a/tests/SqlRunboard.Core.Tests/RestoreTests.cs b/tests/SqlRunboard.Core.Tests/RestoreTests.cs index abf0ad1..dd3375d 100644 --- a/tests/SqlRunboard.Core.Tests/RestoreTests.cs +++ b/tests/SqlRunboard.Core.Tests/RestoreTests.cs @@ -1,7 +1,7 @@ using SqlRunboard.Core; /// A restart: a new BatchList gets the batch file paths that the old one reported, and reads the last runs from disk. -public sealed class RestoreTests : IDisposable +public sealed class RestoreTests : IAsyncDisposable { readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-restore-").FullName; readonly FakeTime time = new(); @@ -9,17 +9,21 @@ public sealed class RestoreTests : IDisposable readonly BatchList list; readonly BatchList.Subscription sub; IReadOnlyList files = []; + readonly List lists = []; public RestoreTests() { executor = new FakeSqlExecutor(time); list = new BatchList(executor, time) { FilesChanged = f => files = f }; sub = list.Subscribe(); + lists.Add(list); } - public void Dispose() + /// Stops the runs of all lists first: a late script end writes its log in root. + public async ValueTask DisposeAsync() { sub.Dispose(); + foreach (var l in lists) await l.StopAsync(); Directory.Delete(root, recursive: true); } @@ -46,6 +50,7 @@ async Task Until(int id, Func ok) BatchList Restart() { var again = new BatchList(executor, time); + lists.Add(again); again.Restore(files); return again; } diff --git a/tests/SqlRunboard.Core.Tests/RunLogTests.cs b/tests/SqlRunboard.Core.Tests/RunLogTests.cs index 3d386f7..f9790a7 100644 --- a/tests/SqlRunboard.Core.Tests/RunLogTests.cs +++ b/tests/SqlRunboard.Core.Tests/RunLogTests.cs @@ -1,7 +1,7 @@ using SqlRunboard.Core; /// The log folder of a run: run.log and summary.csv. Seen through BatchList and the files on disk. FakeTime local zone is +02:00. -public sealed class RunLogTests : IDisposable +public sealed class RunLogTests : IAsyncDisposable { readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-log-").FullName; readonly string appData; @@ -18,9 +18,11 @@ public RunLogTests() sub = list.Subscribe(); } - public void Dispose() + /// Stops the runs first: a late script end writes its log in root. + public async ValueTask DisposeAsync() { sub.Dispose(); + await list.StopAsync(); Directory.Delete(root, recursive: true); } @@ -188,7 +190,7 @@ public async Task A_saved_batch_logs_next_to_its_file_and_a_save_after_a_run_lea list.Save(b.Id, Path.Combine(root, "deploy"), overwrite: false); time.Advance(TimeSpan.FromSeconds(61)); list.Run(b.Id); - await Until(b.Id, Ended); + await Until(b.Id, r => Ended(r) && r.Start == time.GetUtcNow()); // the second run: the Save event still has the first one Assert.True(File.Exists(Path.Combine(appData, "logs", "Untitled 1_20261009-140000", "summary.csv"))); Assert.True(File.Exists(Path.Combine(root, "logs", "deploy_20261009-140101", "summary.csv"))); diff --git a/tests/SqlRunboard.Core.Tests/RunTests.cs b/tests/SqlRunboard.Core.Tests/RunTests.cs index 8dfcbaf..66681a2 100644 --- a/tests/SqlRunboard.Core.Tests/RunTests.cs +++ b/tests/SqlRunboard.Core.Tests/RunTests.cs @@ -1,7 +1,7 @@ using SqlRunboard.Core; /// The run engine, seen through BatchList: Run, then the published batch changes. Fake executor + fake clock. -public sealed class RunTests : IDisposable +public sealed class RunTests : IAsyncDisposable { readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-run-").FullName; readonly FakeTime time = new(); @@ -346,9 +346,11 @@ public async Task Load_again_with_another_target_ends_the_run() Assert.True(list.Reload(b.Id)!.Run!.Ended); } - public void Dispose() + /// Stops the runs first: a late script end writes its log in root. + public async ValueTask DisposeAsync() { sub.Dispose(); + await list.StopAsync(); Directory.Delete(root, recursive: true); } } diff --git a/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs b/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs index 4365979..291b70e 100644 --- a/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs +++ b/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs @@ -1,7 +1,7 @@ using SqlRunboard.Core; /// docs/SPEC.md "Script order": move commands, Sort by file name, New and Missing files. Real temp folders. -public sealed class ScriptOrderTests : IDisposable +public sealed class ScriptOrderTests : IAsyncDisposable { readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-order-").FullName; readonly FakeTime time = new(); @@ -114,5 +114,10 @@ public async Task The_side_list_moves_batches_with_the_same_commands() Assert.False(list.MoveBatch(99, MoveTo.Up)); } - public void Dispose() => Directory.Delete(root, recursive: true); + /// Stops the runs first: a late script end writes its log in root. + public async ValueTask DisposeAsync() + { + await list.StopAsync(); + Directory.Delete(root, recursive: true); + } } From 0bc77716422b20c1f99ca03a7e8eec8e53547bbd Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:59:05 +0200 Subject: [PATCH 75/96] test(gui): read the timeline header and row boxes in one call during a live run --- tests/SqlRunboard.Gui.Tests/TimelineTests.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/SqlRunboard.Gui.Tests/TimelineTests.cs b/tests/SqlRunboard.Gui.Tests/TimelineTests.cs index 707779a..f4f745e 100644 --- a/tests/SqlRunboard.Gui.Tests/TimelineTests.cs +++ b/tests/SqlRunboard.Gui.Tests/TimelineTests.cs @@ -179,11 +179,14 @@ public async Task Columns_shows_a_table_with_a_Gantt_column_and_the_strip_and_ax var head = Panel.Locator(".tlg > .striphead"); await Expect(head.Locator("svg.strip")).ToHaveCountAsync(1); await Expect(head.Locator(".axis span").First).ToHaveTextAsync("0:00"); - var h = (await head.BoundingBoxAsync())!; - var chart = (await Row("a.sql").BoundingBoxAsync())!; - Assert.InRange(h.X, chart.X - 1, chart.X + 1); - Assert.InRange(h.Width, chart.Width - 1, chart.Width + 1); - Assert.True(h.Y + h.Height <= chart.Y + 1); + // Both boxes in one read: the live run rebuilds the panel each second, so two reads can get a removed node. + var box = await Panel.EvaluateAsync(""" + p => { const h = p.querySelector('.tlg > .striphead').getBoundingClientRect(), c = p.querySelector(".tlg > .chart[data-path='a.sql']").getBoundingClientRect(); + return [h.x, h.width, h.bottom, c.x, c.width, c.y]; } + """); + Assert.InRange(box[0], box[3] - 1, box[3] + 1); + Assert.InRange(box[1], box[4] - 1, box[4] + 1); + Assert.True(box[2] <= box[5] + 1); await Toggle("Columns").UncheckAsync(); await Expect(Panel.Locator(".tlg > .h")).ToHaveCountAsync(1); From 57e6dd0be7d5da1f3bfd41bd72816628be51a13a Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:01:03 +0200 Subject: [PATCH 76/96] fix(core): start the executors before the run state is published, and hold their first messages --- src/SqlRunboard.Core/BatchList.cs | 21 +++++++++++++++------ tests/SqlRunboard.Core.Tests/RunTests.cs | 1 + 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index fe3bd91..a02a270 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -21,6 +21,7 @@ public sealed class BatchList(ISqlExecutor executor, TimeProvider time, string? readonly Dictionary passwords = []; // in memory only: never in a Batch, a file or an event readonly Dictionary loaded = []; // batch id -> the file text that the app last read or wrote readonly HashSet finishing = []; // one per Running script: ends after Finish wrote its state and log + List? held; // while Pump starts executors: their first messages, to publish after the run state int lastId, lastUntitled; string[] files = []; @@ -421,10 +422,11 @@ public void Failed() } // Call under the lock. Starts Queued scripts up to the parallel limit, then publishes the run. - // The executors start after the publish, so the page sees an attempt before its first message. + // The executors start before the publish, so a Running script that a subscriber sees has started. + // Their messages wait until after the publish, so the page sees an attempt before its first message. void Pump(int id, ActiveRun run) { - List after = []; + List starts = []; while (run.Running < run.Settings.ParallelLimit && run.Queue.Count > 0) { var path = run.Queue[0]; @@ -440,7 +442,7 @@ void Pump(int id, ActiveRun run) run.Add(path, new Attempt(start, start, ScriptStatus.Failed, null, error)); run.Log?.Append($"{Attempt(run, path)}: Failed: {error}"); var n = run.Scripts[path].Attempts.Count; - after.Add(() => AddMessage(id, run, path, n, new(SqlMessageKind.Error, error))); + starts.Add(() => AddMessage(id, run, path, n, new(SqlMessageKind.Error, error))); run.Failed(); continue; } @@ -456,7 +458,7 @@ void OnMessage(SqlMessage m) run.Log?.Append($"{attempt}: {(m.Kind == SqlMessageKind.Error ? "Error: " : "")}{m.Text}"); // outside the lock: a file write lock (gate) AddMessage(id, run, path, number, m); } - after.Add(() => + starts.Add(() => { Task? done = null; // The continuation takes the lock, so it cannot remove 'done' before this method adds it. @@ -465,11 +467,16 @@ void OnMessage(SqlMessage m) finishing.Add(done); }); } + var outer = held; + held = []; + foreach (var a in starts) a(); + var messages = held; + held = outer; run.Log?.Update(run.Scripts.Values, run.Settings.Target); // also for a run that is no longer current: its late ends go to its log var i = batches.FindIndex(b => b.Id == id); if (i >= 0 && runs.GetValueOrDefault(id) == run) Publish(new Change("batch", batches[i] = batches[i] with { Run = new RunState(run.Start, time.GetUtcNow(), [.. run.Scripts.Values], [.. run.Queue]) })); - foreach (var a in after) a(); + foreach (var m in messages) Publish(m); } // Call under the lock. Keeps the last MaxMessages of the attempt, and sends the message to the page if the batch shows this run. @@ -480,7 +487,9 @@ void AddMessage(int id, ActiveRun run, string path, int attempt, SqlMessage m) kept.Enqueue(message); if (kept.Count > MaxMessages) { kept.Dequeue(); earlier++; } run.Messages[(path, attempt)] = (kept, earlier); - if (shown.GetValueOrDefault(id) == run) Publish(new Change("message", new ScriptMessage(id, path, attempt, message.Time, m.Kind, m.Text))); + if (shown.GetValueOrDefault(id) != run) return; + var change = new Change("message", new ScriptMessage(id, path, attempt, message.Time, m.Kind, m.Text)); + if (held is not null) held.Add(change); else Publish(change); } void Finish(int id, ActiveRun run, string path, DateTimeOffset start, Task t) diff --git a/tests/SqlRunboard.Core.Tests/RunTests.cs b/tests/SqlRunboard.Core.Tests/RunTests.cs index 66681a2..eede837 100644 --- a/tests/SqlRunboard.Core.Tests/RunTests.cs +++ b/tests/SqlRunboard.Core.Tests/RunTests.cs @@ -77,6 +77,7 @@ public async Task A_limit_of_1_gives_an_ordered_run() { var run = await Until(b.Id, r => r.Scripts.Count(s => s.Status == ScriptStatus.Running) == 1 && r.Scripts.Count(s => s.Status == ScriptStatus.Succeeded) == n); Assert.Equal(1, run.Scripts.Count(s => s.Status == ScriptStatus.Running)); + Assert.Equal(n + 1, executor.Calls.Count); // a published Running script has started: its 5 s wait is on the clock time.Advance(TimeSpan.FromSeconds(5)); } From 01dc12d8607336996eb5a3266bd87fec83860ced Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:01:20 +0200 Subject: [PATCH 77/96] fix(core): fail an attempt whose executor throws at once, and always end its finish task --- src/SqlRunboard.Core/BatchList.cs | 10 ++++++++-- tests/SqlRunboard.Core.Tests/RunTests.cs | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index a02a270..d0e191a 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -460,10 +460,16 @@ void OnMessage(SqlMessage m) } starts.Add(() => { + Task task; + try { task = executor.ExecuteAsync(execution, OnMessage, cts.Token); } + catch (Exception e) { task = Task.FromException(e); } // a throw before the first await fails the attempt too Task? done = null; // The continuation takes the lock, so it cannot remove 'done' before this method adds it. - done = executor.ExecuteAsync(execution, OnMessage, cts.Token) - .ContinueWith(t => { Finish(id, run, path, start, t); lock (gate) finishing.Remove(done!); }, TaskScheduler.Default); + done = task.ContinueWith(t => + { + try { Finish(id, run, path, start, t); } + finally { lock (gate) finishing.Remove(done!); } + }, TaskScheduler.Default); finishing.Add(done); }); } diff --git a/tests/SqlRunboard.Core.Tests/RunTests.cs b/tests/SqlRunboard.Core.Tests/RunTests.cs index eede837..106ea6b 100644 --- a/tests/SqlRunboard.Core.Tests/RunTests.cs +++ b/tests/SqlRunboard.Core.Tests/RunTests.cs @@ -85,6 +85,29 @@ public async Task A_limit_of_1_gives_an_ordered_run() Assert.Equal(["a.sql", "b.sql", "c.sql"], executor.Calls.Select(c => c.ScriptName)); } + sealed class ThrowingExecutor : ISqlExecutor + { + public Task ExecuteAsync(SqlExecution execution, Action onMessage, CancellationToken ct) => throw new InvalidOperationException("no connection"); + public Task LogonAsync(SqlConnectionSettings connection, CancellationToken ct) => throw new NotSupportedException(); + } + + [Fact] + public async Task An_executor_that_throws_at_once_fails_the_attempt_and_the_queue_continues() + { + var throwing = new BatchList(new ThrowingExecutor(), time); + Write("a.sql"); Write("b.sql"); + var b = throwing.NewBatch(); + throwing.UpdateSettings(b.Id, new BatchSettings { Folder = root, ParallelLimit = 1 }); + using var changes = throwing.Subscribe(); + + throwing.Run(b.Id); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + RunState? run = null; + while (run is null) if ((await changes.Changes.ReadAsync(cts.Token)).Data is Batch { Run: { Active: false } r }) run = r; + Assert.All(run.Scripts, s => Assert.Equal((ScriptStatus.Failed, "no connection"), (s.Status, s.Attempts.Single().Error))); + } + [Fact] public async Task A_script_is_sent_as_GO_parts_and_a_BOM_selects_the_encoding() { From 7fbcddd5075215c43cfae951477a5387c8ac9ccf Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:02:21 +0200 Subject: [PATCH 78/96] fix(gui): write settings.json with a rename and keep a batch command working when the write fails --- src/SqlRunboard.Gui/GuiHost.cs | 19 ++++++++++++++----- .../SqlRunboard.Gui.Tests/AppSettingsTests.cs | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 4f337f9..1a75b00 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -67,7 +67,7 @@ public static async Task StartAsync(GuiHostOptions options) builder.Services.ConfigureHttpJsonOptions(j => j.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); var app = builder.Build(); - RestoreBatches(app.Services.GetRequiredService(), app.Services.GetRequiredService()); + RestoreBatches(app.Services.GetRequiredService(), app.Services.GetRequiredService(), app.Logger); app.Use(RejectForeignRequests); app.Use(RequireToken(token)); var files = new EmbeddedFileProvider(typeof(GuiHost).Assembly, "SqlRunboard.Gui.wwwroot"); @@ -145,13 +145,20 @@ public static async Task StartAsync(GuiHostOptions options) return host; } - /// The side list comes back from the app settings, and each change of its batch files goes there. - static void RestoreBatches(BatchList list, AppSettings settings) + /// + /// The side list comes back from the app settings, and each change of its batch files goes there. + /// A write error does not fail the batch command that changed the list: the next change of the list writes it again. + /// + static void RestoreBatches(BatchList list, AppSettings settings, ILogger log) { const string Name = "batchFiles"; var files = settings.Snapshot()[Name] as JsonArray ?? []; list.Restore(files.Where(f => f?.GetValueKind() == JsonValueKind.String).Select(f => f!.GetValue())); - list.FilesChanged = f => settings.Merge(new JsonObject { [Name] = new JsonArray([.. f.Select(x => (JsonNode)x)]) }); + list.FilesChanged = f => + { + try { settings.Merge(new JsonObject { [Name] = new JsonArray([.. f.Select(x => (JsonNode)x)]) }); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { log.LogWarning("Cannot write {File}: {Error}", AppSettings.FileName, e.Message); } + }; } /// A batch file command: the batch, or a problem whose detail the page shows. 409: the file changed on disk. @@ -274,7 +281,9 @@ public void Merge(JsonObject changes) { var v = Load(); foreach (var (name, value) in changes) v[name] = value?.DeepClone(); - File.WriteAllText(path, v.ToJsonString()); + // A temporary file and a rename: a crash during the write cannot leave half a file. + File.WriteAllText(path + ".tmp", v.ToJsonString()); + File.Move(path + ".tmp", path, overwrite: true); } } diff --git a/tests/SqlRunboard.Gui.Tests/AppSettingsTests.cs b/tests/SqlRunboard.Gui.Tests/AppSettingsTests.cs index 5de2aaa..68e8298 100644 --- a/tests/SqlRunboard.Gui.Tests/AppSettingsTests.cs +++ b/tests/SqlRunboard.Gui.Tests/AppSettingsTests.cs @@ -1,7 +1,9 @@ using System.Net; +using System.Net.Http.Json; using System.Net.ServerSentEvents; using System.Text; using System.Text.Json; +using SqlRunboard.Core; using SqlRunboard.Gui; public class AppSettingsTests @@ -47,6 +49,22 @@ public async Task A_setting_is_kept_in_the_app_data_folder_after_a_restart() Assert.Equal("Dark", settings.GetProperty("theme").GetString()); // a POST changes only the names it sends } + [Fact] + public async Task A_settings_file_that_cannot_be_written_does_not_fail_Open() + { + await using var app = await TestApp.StartAsync(); + var folder = Directory.CreateTempSubdirectory("sqlrunboard-open-").FullName; + var file = Path.Combine(folder, "x.sqlrunboard.json"); + File.WriteAllText(file, BatchFile.Format(new BatchSettings(), [])); + Directory.CreateDirectory(Path.Combine(app.AppData, AppSettings.FileName)); // a folder in its place: each write fails + using var http = app.Client(); + + var res = await http.PostAsJsonAsync("/api/batches/open", new { path = file }, Ct); + + Assert.Equal(HttpStatusCode.OK, res.StatusCode); // the side list has the batch + Directory.Delete(folder, recursive: true); + } + [Theory] [InlineData("[]")] [InlineData("\"table\"")] From 11bac60dd05bf16351efdc18ba2dfcd323dd81f4 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:04:08 +0200 Subject: [PATCH 79/96] fix(gui): end the event streams when the app stops, and stop the runs before Quit answers --- src/SqlRunboard.Gui/GuiHost.cs | 36 +++++++++++++++++----- tests/SqlRunboard.Gui.Tests/EventsTests.cs | 14 +++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 1a75b00..ecbe0eb 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -75,7 +75,8 @@ public static async Task StartAsync(GuiHostOptions options) app.UseStaticFiles(new StaticFileOptions { FileProvider = files }); // Server to page: SSE. Page to server: POST with a JSON body, one route per command. - app.MapGet("/events", (BatchList list, AppSettings settings, CancellationToken ct) => TypedResults.ServerSentEvents(Events(list, settings, ct))); + app.MapGet("/events", (BatchList list, AppSettings settings, IHostApplicationLifetime lifetime, CancellationToken ct) => + TypedResults.ServerSentEvents(Events(list, settings, lifetime.ApplicationStopping, ct))); app.MapPost("/api/app-settings", (JsonNode? changes, AppSettings settings) => { if (changes is not JsonObject o) return Results.BadRequest(); @@ -110,8 +111,13 @@ public static async Task StartAsync(GuiHostOptions options) app.MapPost("/api/batches/{id:int}/save", (int id, SaveRequest r, BatchList list) => FileResult(() => list.Save(id, r.Path, r.Overwrite))); app.MapPost("/api/batches/{id:int}/reload", (int id, BatchList list) => FileResult(() => list.Reload(id))); app.MapPost("/api/batches/{id:int}/remove", (int id, BatchList list) => list.Remove(id) ? Results.Ok(new { }) : Results.NotFound()); - // Quit: the page asked to save and confirmed. The stop itself runs in the ApplicationStopping handler below. - app.MapPost("/api/quit", (IHostApplicationLifetime lifetime) => { lifetime.StopApplication(); return Results.Ok(new { }); }); + // Quit: the page asked to save and confirmed. The answer comes when the scripts stopped and the logs are written. + app.MapPost("/api/quit", async (BatchList list, IHostApplicationLifetime lifetime) => + { + await StopRunsAsync(list); + lifetime.StopApplication(); // the ApplicationStopping handler below then has no runs to stop + return Results.Ok(new { }); + }); app.MapPost("/api/app-data/open", (GuiHostOptions o) => { // UseShellExecute on a folder opens the file manager: Explorer, Finder or xdg-open. @@ -132,10 +138,10 @@ public static async Task StartAsync(GuiHostOptions options) var lockPath = Path.Combine(options.AppDataFolder, LockFileName); // Quit, Ctrl+C (ConsoleLifetime), SIGTERM and DisposeAsync all come here: cancel all scripts, write the logs, remove the lock file. + // The handler must be synchronous; Quit stops the runs before, so only Ctrl+C, SIGTERM and DisposeAsync wait here. app.Lifetime.ApplicationStopping.Register(() => { - // ponytail: a script that does not stop in 10 s keeps no final log line; raise the limit if a real cancel takes longer. - app.Services.GetRequiredService().StopAsync().Wait(TimeSpan.FromSeconds(10)); + StopRunsAsync(app.Services.GetRequiredService()).GetAwaiter().GetResult(); File.Delete(lockPath); }); @@ -145,6 +151,14 @@ public static async Task StartAsync(GuiHostOptions options) return host; } + /// Cancels all scripts and waits until their logs are written. + static async Task StopRunsAsync(BatchList list) + { + // ponytail: a script that does not stop in 10 s keeps no final log line; raise the limit if a real cancel takes longer. + try { await list.StopAsync().WaitAsync(TimeSpan.FromSeconds(10)); } + catch (TimeoutException) { } + } + /// /// The side list comes back from the app settings, and each change of its batch files goes there. /// A write error does not fail the batch command that changed the list: the next change of the list writes it again. @@ -202,12 +216,18 @@ void WriteLock() JsonSerializer.Serialize(stream, new LockFile(BaseUrl, Token), JsonSerializerOptions.Web); } - static async IAsyncEnumerable> Events(BatchList list, AppSettings settings, [EnumeratorCancellation] CancellationToken ct) + /// The stream ends when the page goes away, or when the app stops: the server then does not wait for the page. + static async IAsyncEnumerable> Events(BatchList list, AppSettings settings, CancellationToken stopping, [EnumeratorCancellation] CancellationToken ct) { using var sub = list.Subscribe(); + using var end = CancellationTokenSource.CreateLinkedTokenSource(ct, stopping); yield return new SseItem(new { batches = sub.Snapshot, app = settings.Snapshot(), messages = sub.Messages }, "snapshot"); - await foreach (var change in sub.Changes.ReadAllAsync(ct)) - yield return new SseItem(change.Data, change.Kind); + while (true) + { + try { if (!await sub.Changes.WaitToReadAsync(end.Token)) yield break; } + catch (OperationCanceledException) { yield break; } + while (sub.Changes.TryRead(out var change)) yield return new SseItem(change.Data, change.Kind); + } } public BatchList Batches => app.Services.GetRequiredService(); diff --git a/tests/SqlRunboard.Gui.Tests/EventsTests.cs b/tests/SqlRunboard.Gui.Tests/EventsTests.cs index 6932013..03ec7bd 100644 --- a/tests/SqlRunboard.Gui.Tests/EventsTests.cs +++ b/tests/SqlRunboard.Gui.Tests/EventsTests.cs @@ -37,4 +37,18 @@ public async Task The_stream_sends_the_full_state_first_then_each_change() Assert.Equal("Untitled 2", change.GetProperty("name").GetString()); Assert.NotEqual(first.GetProperty("id").GetInt32(), change.GetProperty("id").GetInt32()); } + + [Fact] + public async Task Stopping_the_server_ends_an_open_stream() + { + var app = await TestApp.StartAsync(); + using var http = app.Client(); + await using var stream = await http.GetStreamAsync("/events", Ct); + await using var events = SseParser.Create(stream).EnumerateAsync(Ct).GetAsyncEnumerator(Ct); + Assert.True(await events.MoveNextAsync()); // the snapshot + + await app.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10), Ct); // not the shutdown timeout of the host + + Assert.False(await events.MoveNextAsync()); + } } From 8c5e5464e7b511829d5a91a64da8c42f05a4e0d9 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:04:27 +0200 Subject: [PATCH 80/96] test(gui): bound each process wait of the executable tests and kill the process at the end --- tests/SqlRunboard.Gui.Tests/ExecutableTests.cs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs b/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs index b1d909d..c4c186a 100644 --- a/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs +++ b/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs @@ -86,7 +86,7 @@ public async Task A_second_start_opens_the_running_server_and_stops() try { var output = await exe.StandardOutput.ReadToEndAsync(Ct).WaitAsync(TimeSpan.FromSeconds(30), Ct); - await exe.WaitForExitAsync(Ct); + await exe.WaitForExitAsync(Ct).WaitAsync(TimeSpan.FromSeconds(30), Ct); Assert.Equal(0, exe.ExitCode); Assert.Equal($"SQLRunboard: {app.Host.StartUrl}", output.Trim()); @@ -106,10 +106,17 @@ public async Task An_unknown_argument_prints_the_usage_and_exits_with_code_2() RedirectStandardError = true, }; using var exe = Process.Start(psi)!; - var error = await exe.StandardError.ReadToEndAsync(Ct); - await exe.WaitForExitAsync(Ct); + try + { + var error = await exe.StandardError.ReadToEndAsync(Ct).WaitAsync(TimeSpan.FromSeconds(30), Ct); + await exe.WaitForExitAsync(Ct).WaitAsync(TimeSpan.FromSeconds(30), Ct); - Assert.Equal(2, exe.ExitCode); - Assert.Contains("Usage: sqlrunboard", error); + Assert.Equal(2, exe.ExitCode); + Assert.Contains("Usage: sqlrunboard", error); + } + finally + { + if (!exe.HasExited) exe.Kill(entireProcessTree: true); + } } } From 6159b64cb4fae6e9bdab01588141a269b5d19743 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:04:41 +0200 Subject: [PATCH 81/96] test(gui): delete the temp folder of the Quit page tests --- tests/SqlRunboard.Gui.Tests/QuitTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/SqlRunboard.Gui.Tests/QuitTests.cs b/tests/SqlRunboard.Gui.Tests/QuitTests.cs index c06567f..b9aac22 100644 --- a/tests/SqlRunboard.Gui.Tests/QuitTests.cs +++ b/tests/SqlRunboard.Gui.Tests/QuitTests.cs @@ -120,4 +120,10 @@ public async Task During_a_run_Quit_asks_for_confirmation_and_then_cancels_all_s var csv = Directory.GetFiles(Path.Combine(root, "logs"), "summary.csv", SearchOption.AllDirectories).Single(); Assert.Contains("a.sql,1,Cancelled", File.ReadAllText(csv)); } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + Directory.Delete(root, recursive: true); + } } From 4736b209a2aefac22a6058c23e93046ee15683b6 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:05:02 +0200 Subject: [PATCH 82/96] test(core): cover the log end of a batch that you remove during its run --- tests/SqlRunboard.Core.Tests/RunLogTests.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/SqlRunboard.Core.Tests/RunLogTests.cs b/tests/SqlRunboard.Core.Tests/RunLogTests.cs index f9790a7..754a9d4 100644 --- a/tests/SqlRunboard.Core.Tests/RunLogTests.cs +++ b/tests/SqlRunboard.Core.Tests/RunLogTests.cs @@ -226,6 +226,21 @@ public async Task A_failed_rename_warns_in_run_log_and_on_the_page_and_the_next_ Assert.Contains("2.sql,1,Succeeded", File.ReadAllText(csv)); } + [Fact] + public async Task A_batch_removed_during_a_run_still_ends_its_log() + { + var b = NewBatch(new BatchSettings { ParallelLimit = 1 }, "1.sql", "2.sql"); + Script("1.sql", 60); + list.Run(b.Id); + + Assert.True(list.Remove(b.Id)); + await list.StopAsync().WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); // waits for the late end of 1.sql + + var folder = Path.Combine(appData, "logs", "Untitled 1_20261009-140000"); + Assert.Contains("1.sql,1,Cancelled", File.ReadAllText(Path.Combine(folder, "summary.csv"))); + Assert.Contains("Run ended: 2 scripts, 0 succeeded, 0 failed, 2 cancelled.", File.ReadAllText(Path.Combine(folder, "run.log"))); + } + [Fact] public async Task Stop_cancels_all_scripts_of_all_batches_and_ends_when_the_logs_are_written() { From bba225178d41041493f70396815099d726b0771f Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:07:49 +0200 Subject: [PATCH 83/96] fix: give each batch state a version so the page drops a late older state --- src/SqlRunboard.Core/Batch.cs | 3 +- src/SqlRunboard.Core/BatchList.cs | 57 +++++++++------------ src/SqlRunboard.Gui/wwwroot/index.html | 1 + tests/SqlRunboard.Gui.Tests/RunViewTests.cs | 28 ++++++++++ 4 files changed, 56 insertions(+), 33 deletions(-) diff --git a/src/SqlRunboard.Core/Batch.cs b/src/SqlRunboard.Core/Batch.cs index 903030f..7424439 100644 --- a/src/SqlRunboard.Core/Batch.cs +++ b/src/SqlRunboard.Core/Batch.cs @@ -46,9 +46,10 @@ public enum ScriptState { New, Missing } /// /// One entry in the side list. File is null for an unsaved batch. Scripts is the start order. Run is the current run, or null. /// FileError: the app could not read the batch file at the start (for example "File not found"). The entry stays until you remove it. +/// Version: each change of the batch gets a higher number, so the page can drop a state that is older than the one it has. /// public sealed record Batch(int Id, string Name, string? File, bool Dirty, BatchSettings Settings, IReadOnlyList Scripts, RunState? Run = null, - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? FileError = null); + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? FileError = null, long Version = 0); /// The move commands of docs/SPEC.md "Script order". One item at a time. public enum MoveTo { Up, Down, Top, Bottom } diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index d0e191a..07cc177 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -23,6 +23,7 @@ public sealed class BatchList(ISqlExecutor executor, TimeProvider time, string? readonly HashSet finishing = []; // one per Running script: ends after Finish wrote its state and log List? held; // while Pump starts executors: their first messages, to publish after the run state int lastId, lastUntitled; + long version; string[] files = []; /// @@ -45,8 +46,7 @@ public void Restore(IEnumerable paths) { b = b with { FileError = e is FileNotFoundException or DirectoryNotFoundException ? "File not found" : e.Message }; } - batches.Add(b); - Publish(new Change("batch", b)); + Put(b); } } @@ -57,10 +57,7 @@ public Batch NewBatch() { lock (gate) { - var batch = new Batch(++lastId, $"Untitled {++lastUntitled}", File: null, Dirty: true, new BatchSettings(), Scripts: []); - batches.Add(batch); - Publish(new Change("batch", batch)); - return batch; + return Put(new Batch(++lastId, $"Untitled {++lastUntitled}", File: null, Dirty: true, new BatchSettings(), Scripts: [])); } } @@ -177,9 +174,7 @@ static bool MoveIn(List list, Predicate match, MoveTo to) if (path is not null && !Path.IsPathFullyQualified(path)) throw new ArgumentException($"Type a full path: {path}"); lock (gate) { - var i = batches.FindIndex(b => b.Id == id); - if (i < 0) return null; - var b = batches[i]; + if (Find(id) is not { } b) return null; var file = path is null ? b.File ?? throw new ArgumentException("The batch has no file. Use Save as.") : BatchFile.HasExtension(path) ? Path.GetFullPath(path) : Path.GetFullPath(path + BatchFile.Extension); // ponytail: Save as over another existing file overwrites it without a question; ask first if users lose files that way. @@ -190,9 +185,7 @@ static bool MoveIn(List list, Predicate match, MoveTo to) var text = BatchFile.Format(settings, scripts); File.WriteAllText(file, text); loaded[id] = text; - var batch = batches[i] = b with { Name = BatchFile.NameOf(file), File = file, Dirty = false, Settings = settings, Scripts = scripts }; - Publish(new Change("batch", batch)); - return batch; + return Put(b with { Name = BatchFile.NameOf(file), File = file, Dirty = false, Settings = settings, Scripts = scripts }); } } @@ -208,10 +201,7 @@ public Batch Open(string path) { // ponytail: an ordinal path compare; the same file with another letter case on Windows or macOS gets a second entry. if (batches.Find(b => b.File == file) is { } open) return open; - var batch = Load(new Batch(++lastId, BatchFile.NameOf(file), file, Dirty: false, new BatchSettings(), Scripts: [])); - batches.Add(batch); - Publish(new Change("batch", batch)); - return batch; + return Put(Load(new Batch(++lastId, BatchFile.NameOf(file), file, Dirty: false, new BatchSettings(), Scripts: []))); } } @@ -220,12 +210,9 @@ public Batch Open(string path) { lock (gate) { - var i = batches.FindIndex(b => b.Id == id); - if (i < 0) return null; - if (batches[i].File is null) throw new ArgumentException("The batch has no file."); - var batch = batches[i] = Load(batches[i]); - Publish(new Change("batch", batch)); - return batch; + if (Find(id) is not { } b) return null; + if (b.File is null) throw new ArgumentException("The batch has no file."); + return Put(Load(b)); } } @@ -290,12 +277,19 @@ public bool Remove(int id) // Call under the lock. Batch? Replace(int id, Func change) { - var i = batches.FindIndex(b => b.Id == id); - if (i < 0) return null; - if (batches[i].Run?.Active == true) return batches[i]; // the run locks the settings and the check boxes - var batch = batches[i] = change(batches[i]) with { Dirty = true }; - Publish(new Change("batch", batch)); - return batch; + if (Find(id) is not { } b) return null; + if (b.Run?.Active == true) return b; // the run locks the settings and the check boxes + return Put(change(b) with { Dirty = true }); + } + + // Call under the lock. Stores the batch with the next version (a new batch goes at the end of the list) and publishes it. + Batch Put(Batch b) + { + b = b with { Version = ++version }; + var i = batches.FindIndex(x => x.Id == b.Id); + if (i < 0) batches.Add(b); else batches[i] = b; + Publish(new Change("batch", b)); + return b; } /// @@ -323,7 +317,7 @@ public bool Remove(int id) run.Queue.Add(s.Path); } Pump(id, run); - return batches[i]; + return Find(id); } } @@ -479,9 +473,8 @@ void OnMessage(SqlMessage m) var messages = held; held = outer; run.Log?.Update(run.Scripts.Values, run.Settings.Target); // also for a run that is no longer current: its late ends go to its log - var i = batches.FindIndex(b => b.Id == id); - if (i >= 0 && runs.GetValueOrDefault(id) == run) - Publish(new Change("batch", batches[i] = batches[i] with { Run = new RunState(run.Start, time.GetUtcNow(), [.. run.Scripts.Values], [.. run.Queue]) })); + if (runs.GetValueOrDefault(id) == run && Find(id) is { } b) + Put(b with { Run = new RunState(run.Start, time.GetUtcNow(), [.. run.Scripts.Values], [.. run.Queue]) }); foreach (var m in messages) Publish(m); } diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index bdc666d..a48ec45 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -226,6 +226,7 @@ target: { server: s.server, database: s.database, auth: s.auth, user: s.user, passwordVariable: s.passwordVariable, encrypt: s.encrypt, trustServerCertificate: s.trustServerCertificate, extraConnectionString: s.extra } }); function upsert(sb) { let b = app.batches.find(x => x.id === sb.id); + if (b && sb.version < b.version) return b; // an older state: a POST answer can come after newer events if (!b) app.batches.push(b = { missing: false, tab: null, sel: null, drawer: false, run: null, scripts: [], signing: null, logonMsg: null, testMsg: null, sort: null, fileSel: null }); if (!b.msgs || sb.run?.start !== b.runStart) { b.msgs = {}; b.warnings = []; b.runStart = sb.run?.start; } // a new run: its messages come after this event const mine = b.saving ? b.settings : null; // while my settings are on the way, an older echo must not undo them diff --git a/tests/SqlRunboard.Gui.Tests/RunViewTests.cs b/tests/SqlRunboard.Gui.Tests/RunViewTests.cs index 01a3b57..11dc0e1 100644 --- a/tests/SqlRunboard.Gui.Tests/RunViewTests.cs +++ b/tests/SqlRunboard.Gui.Tests/RunViewTests.cs @@ -95,6 +95,34 @@ public async Task The_Run_tab_opens_the_last_view_also_after_a_restart() await Expect(page.GetByRole(AriaRole.Button, new() { Name = "Table" })).ToHaveAttributeAsync("aria-pressed", "true"); } + [Fact] + public async Task A_late_answer_to_Run_does_not_undo_the_newer_state_from_the_server() + { + File.WriteAllText(Path.Combine(scripts, "a.sql"), "select 1"); + App.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(10) }; + var b = App.Host.Batches.NewBatch(); + App.Host.Batches.UpdateSettings(b.Id, new BatchSettings { Folder = scripts }); + var answer = new TaskCompletionSource(); + await Page.RouteAsync("**/api/batches/*/run", async route => + { + var res = await route.FetchAsync(); // the server state when the run started: a.sql Running + await answer.Task; + await route.FulfillAsync(new() { Response = res }); + }); + await Side(b.Name).ClickAsync(); + + await Page.Locator("#hdr").GetByRole(AriaRole.Button, new() { Name = "Run", Exact = true }).ClickAsync(); + await Expect(Side(b.Name)).ToContainTextAsync("0/1"); // from the event stream + App.Time.Advance(TimeSpan.FromSeconds(10)); + await Expect(Side(b.Name)).ToContainTextAsync("1/1"); + answer.SetResult(); + + await Expect(Page.GetByRole(AriaRole.Tab, new() { Name = "Run" })).ToHaveAttributeAsync("aria-selected", "true"); // the page has the answer + await Expect(Side(b.Name)).ToContainTextAsync("1/1"); + await Expect(Panel.Locator(".card:has-text('a.sql')")).ToContainTextAsync("attempt 1"); + await Expect(Panel.GetByRole(AriaRole.Region, new() { Name = "Succeeded", Exact = true }).Locator(".card")).ToHaveCountAsync(1); + } + public override async ValueTask DisposeAsync() { await base.DisposeAsync(); From 060aa66119eb9319ac387fbb240e85546337632d Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:08:43 +0200 Subject: [PATCH 84/96] fix: refuse Load again while the run has Queued or Running scripts, and keep the run target --- src/SqlRunboard.Core/BatchList.cs | 8 ++++++-- src/SqlRunboard.Gui/GuiHost.cs | 4 ++-- src/SqlRunboard.Gui/wwwroot/index.html | 2 +- tests/SqlRunboard.Core.Tests/RunTests.cs | 18 ++++++++++++++++++ 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index 07cc177..774aa1e 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -205,13 +205,17 @@ public Batch Open(string path) } } - /// "Load again": replaces the batch with its file on disk. Null if no batch has this id. + /// + /// "Load again": replaces the batch with its file on disk. Null if no batch has this id. + /// Throws InvalidOperationException while the run has Queued or Running scripts: the run locks the settings. + /// public Batch? Reload(int id) { lock (gate) { if (Find(id) is not { } b) return null; if (b.File is null) throw new ArgumentException("The batch has no file."); + if (b.Run?.Active == true) throw new InvalidOperationException("The run locks the batch. Load it again after the run."); return Put(Load(b)); } } @@ -377,7 +381,7 @@ public Task StopAsync() { if (runs.GetValueOrDefault(id) is { } run && Find(id) is { } batch) { - run.Settings = batch.Settings; // reruns use the current parallel limit, stop setting and timeout + run.Settings = batch.Settings with { Target = run.Settings.Target }; // reruns use the current parallel limit, stop setting and timeout foreach (var path in paths(run)) if (run.Scripts.TryGetValue(path, out var s) && s.Status is ScriptStatus.Succeeded or ScriptStatus.Failed or ScriptStatus.Cancelled) { diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index ecbe0eb..38f56fa 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -175,11 +175,11 @@ static void RestoreBatches(BatchList list, AppSettings settings, ILogger log) }; } - /// A batch file command: the batch, or a problem whose detail the page shows. 409: the file changed on disk. + /// A batch file command: the batch, or a problem whose detail the page shows. 409: the file changed on disk, or the run locks the batch. static IResult FileResult(Func command) { try { return command() is { } b ? Results.Ok(b) : Results.NotFound(); } - catch (BatchFileChangedException e) { return Results.Problem(e.Message, statusCode: StatusCodes.Status409Conflict); } + catch (Exception e) when (e is BatchFileChangedException or InvalidOperationException) { return Results.Problem(e.Message, statusCode: StatusCodes.Status409Conflict); } catch (Exception e) when (e is FileNotFoundException or DirectoryNotFoundException) { return Results.Problem(e.Message, statusCode: StatusCodes.Status404NotFound); } catch (Exception e) when (e is ArgumentException or InvalidDataException) { return Results.Problem(e.Message, statusCode: StatusCodes.Status400BadRequest); } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return Results.Problem(e.Message, statusCode: StatusCodes.Status500InternalServerError); } diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index a48ec45..7c842d2 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -699,7 +699,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')}

Type or paste a path, or select a ${EXT} file.

${fileList(d)}
`, changed: () => `

The file changed on disk

${esc(d.b.file)}

Another program changed the file after the app loaded it.

-
`, +
`, folder: () => `

Select a folder

${esc(d.path)}

${d.parent ? `` : ''}${d.folders.map(f => ``).join('')}
diff --git a/tests/SqlRunboard.Core.Tests/RunTests.cs b/tests/SqlRunboard.Core.Tests/RunTests.cs index 106ea6b..c646540 100644 --- a/tests/SqlRunboard.Core.Tests/RunTests.cs +++ b/tests/SqlRunboard.Core.Tests/RunTests.cs @@ -355,6 +355,24 @@ public async Task A_change_to_the_target_or_the_file_scan_ends_the_run_and_rerun Assert.All(next.Scripts, x => Assert.Single(x.Attempts)); } + [Fact] + public async Task Load_again_is_refused_while_the_run_has_Queued_or_Running_scripts() + { + var b = NewBatch(1, "a.sql", "b.sql"); + Script("a.sql", 5); + var file = Path.Combine(root, "x.sqlrunboard.json"); + b = list.Save(b.Id, file, overwrite: false)!; + list.Run(b.Id); + File.WriteAllText(file, BatchFile.Format(b.Settings with { Target = b.Settings.Target with { Server = "other" } }, b.Scripts)); + + Assert.Throws(() => list.Reload(b.Id)); + + time.Advance(TimeSpan.FromSeconds(5)); + await Until(b.Id, r => !r.Active); + Assert.All(executor.Calls, c => Assert.Null(c.Connection.Target.Server)); // b.sql runs against the target of the run + Assert.True(list.Reload(b.Id)!.Run!.Ended); // after the run: Load again works and ends the run + } + [Fact] public async Task Load_again_with_another_target_ends_the_run() { From 1b745be84922a1a1a94a3af832992b4226b642f3 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:09:15 +0200 Subject: [PATCH 85/96] fix(core): publish the messages of a rerun of a run that came back after a restart --- src/SqlRunboard.Core/BatchList.cs | 2 +- tests/SqlRunboard.Core.Tests/RestoreTests.cs | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index 774aa1e..f6095f5 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -245,7 +245,7 @@ Batch LastRun(Batch b) var ended = scripts.Count > 0 && (server != (target.Server ?? "") || database != (target.Database ?? "")); if (!ended) { - var run = runs[b.Id] = new ActiveRun(start, FolderPath(b.File, b.Settings.Folder) ?? "", b.Settings, RunLog.Open(folder, time, text => Warn(b.Id, text))); + var run = runs[b.Id] = shown[b.Id] = new ActiveRun(start, FolderPath(b.File, b.Settings.Folder) ?? "", b.Settings, RunLog.Open(folder, time, text => Warn(b.Id, text))); foreach (var s in scripts) run.Scripts[s.Path] = s; } return b with { Run = new RunState(start, time.GetUtcNow(), scripts, [], ended) }; diff --git a/tests/SqlRunboard.Core.Tests/RestoreTests.cs b/tests/SqlRunboard.Core.Tests/RestoreTests.cs index dd3375d..9976a1f 100644 --- a/tests/SqlRunboard.Core.Tests/RestoreTests.cs +++ b/tests/SqlRunboard.Core.Tests/RestoreTests.cs @@ -156,6 +156,24 @@ public async Task Rerun_all_failed_continues_an_interrupted_run_in_its_log_folde Assert.Contains("4.sql,1,Succeeded", csv); } + [Fact] + public async Task A_rerun_after_a_restart_publishes_its_messages() + { + await InterruptedRun(); + executor.Scripts.Clear(); + executor.Scripts["2.sql"] = new FakeScript { Messages = [new(SqlMessageKind.Info, "again")] }; + + var again = Restart(); + using var sub2 = again.Subscribe(); + again.Rerun(sub2.Snapshot[0].Id, "2.sql"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + Change c; + do c = await sub2.Changes.ReadAsync(cts.Token); + while (c.Kind != "message"); + Assert.Equal(("2.sql", 2, "again"), (((ScriptMessage)c.Data).Path, ((ScriptMessage)c.Data).Attempt, ((ScriptMessage)c.Data).Text)); + } + [Fact] public async Task A_target_change_while_the_app_was_stopped_ends_the_run() { From 760dabf93cd7ee2fcb1afa21c21999cd1c0e085e Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:10:10 +0200 Subject: [PATCH 86/96] fix(gui): show the error of an attempt that has no messages, as after a restart --- src/SqlRunboard.Gui/wwwroot/index.html | 10 ++++------ tests/SqlRunboard.Gui.Tests/RestartTests.cs | 2 ++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 7c842d2..77d69d4 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -609,12 +609,10 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} else if (!r.attempts.length && r.error) body = `
${esc(r.error)}
`; // did not start for (const at of r.attempts) { body += `
Attempt ${at.n}${at.start != null ? ` · ${clock(b.run, at.start)}` : ''} · ${at.status}
`; - if (at.restored) body += `${at.error ? `
${lineLink(at.error)}
` : ''}
Full messages: run.log
`; - else { - const ms = b.msgs[x.path]?.[at.n]; - if (ms?.earlier) body += `
${ms.earlier.toLocaleString()} earlier messages are in run.log
`; - body += (ms?.list ?? []).map(m => `
${new Date(m.time).toLocaleTimeString([], { hour12: false })} ${m.kind === 'Error' ? lineLink(m.text) : esc(m.text)}
`).join(''); - } + const ms = b.msgs[x.path]?.[at.n]; + if (ms?.earlier) body += `
${ms.earlier.toLocaleString()} earlier messages are in run.log
`; + body += (ms?.list ?? []).map(m => `
${new Date(m.time).toLocaleTimeString([], { hour12: false })} ${m.kind === 'Error' ? lineLink(m.text) : esc(m.text)}
`).join(''); + if (!ms && at.error) body += `
${esc(at.error)}
`; // a run from summary.csv after a restart has no messages } return `
${bubble(r.status)}${esc(x.path)}
diff --git a/tests/SqlRunboard.Gui.Tests/RestartTests.cs b/tests/SqlRunboard.Gui.Tests/RestartTests.cs index 6cc0d4b..89819a0 100644 --- a/tests/SqlRunboard.Gui.Tests/RestartTests.cs +++ b/tests/SqlRunboard.Gui.Tests/RestartTests.cs @@ -47,6 +47,8 @@ public async Task The_side_list_and_the_last_runs_come_back_after_a_restart() await page.Locator("#tabs").GetByRole(AriaRole.Tab, new() { Name = "Run" }).ClickAsync(); await Expect(page.Locator("#panel [data-path='2.sql']")).ToHaveAttributeAsync("aria-label", new System.Text.RegularExpressions.Regex("^2.sql, Cancelled, attempt 1")); await Expect(page.Locator("#hdr").GetByRole(AriaRole.Button, new() { Name = "Rerun all failed" })).ToBeEnabledAsync(); + await page.Locator("#panel [data-path='2.sql']").ClickAsync(); + await Expect(page.Locator("#drawer .msgs .Error")).ToHaveTextAsync("The app stopped"); // the error of the attempt, from summary.csv await side.Nth(2).ClickAsync(); await page.Locator("#tabs").GetByRole(AriaRole.Tab, new() { Name = "Run" }).ClickAsync(); From 0e7ca7c9227adf0e424aec931040184105f0f90c Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:11:04 +0200 Subject: [PATCH 87/96] fix(gui): show 0 attempts for a script that did not start --- src/SqlRunboard.Gui/wwwroot/index.html | 8 ++++---- tests/SqlRunboard.Gui.Tests/RunViewTests.cs | 1 + tests/SqlRunboard.Gui.Tests/TimelineTests.cs | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 77d69d4..38702a6 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -443,7 +443,7 @@ const viewList = b => app.view === 'board' ? boardCols(b).flatMap(c => c[1]) : app.view === 'table' ? tableOrder(b) : runOrder(b); function ariaRow(b, x) { const e = elapsedOf(b, x.r), rows = rowsOf(x.r); - return `${x.path}, ${x.r.virtual ? 'Not run yet' : x.r.status}, attempt ${Math.max(1, x.r.attempts.length)}${e != null ? `, elapsed ${words(e)}` : ''}, ${rows == null ? 'rows unknown' : rows + ' rows'}`; + return `${x.path}, ${x.r.virtual ? 'Not run yet' : x.r.status}, attempt ${x.r.attempts.length}${e != null ? `, elapsed ${words(e)}` : ''}, ${rows == null ? 'rows unknown' : rows + ' rows'}`; } const cur = () => app.batches.find(b => b.id === app.cur); const item = (b, x) => `data-path="${esc(x.path)}" data-k="s:${esc(x.path)}" tabindex="${b.sel === x.path ? 0 : -1}" aria-label="${esc(ariaRow(b, x))}"`; @@ -493,7 +493,7 @@ const e = elapsedOf(b, x.r), rows = rowsOf(x.r), parts = []; if (x.r.status === 'Queued') parts.push(`#${x.q}`); if (e != null) parts.push(dur(e)); - parts.push(`attempt ${Math.max(1, x.r.attempts.length)}`, `${rows == null ? '—' : rows.toLocaleString()} rows`); + parts.push(`attempt ${x.r.attempts.length}`, `${rows == null ? '—' : rows.toLocaleString()} rows`); if (x.r.status === 'Cancelled') parts.push('Cancelled'); if (x.r.status === 'Running' && lastA(x.r).rollback != null) parts.push('Rolling back'); return parts.map(p => `${p}`).join(''); @@ -507,7 +507,7 @@ return `${th('q', '#', 'num')}${th('status', 'Status')}${th('script', 'Script')}${th('start', 'Start')}${th('elapsed', 'Elapsed', 'num')}${th('attempts', 'Attempts', 'num')}${th('rows', 'Rows', 'num')} ${tableOrder(b).map(x => { const a = lastA(x.r), e = elapsedOf(b, x.r), rows = rowsOf(x.r); return ` - + `; }).join('')}
${x.q ?? ''}${bubble(x.r.status)} ${x.r.virtual ? 'Not run yet' : x.r.status}${esc(x.path)}${a?.start != null ? clock(b.run, a.start) : ''}${e != null ? dur(e) : ''}${Math.max(1, x.r.attempts.length)}${a?.start != null ? clock(b.run, a.start) : ''}${e != null ? dur(e) : ''}${x.r.attempts.length} ${rows == null ? '—' : rows.toLocaleString()}
`; } // timeline layout: time -> 0..1, with idle gaps over 1 minute cut when Collapse is on @@ -528,7 +528,7 @@ const gridCols = cols ? '36px 110px minmax(150px, 220px) 70px 64px 56px minmax(300px, 1fr)' : 'minmax(140px, 220px) minmax(300px, 1fr)'; const names = x => cols ? `
${x.q ?? ''}
${bubble(x.r.status)} ${x.r.virtual ? 'Not run yet' : x.r.status}
${esc(x.path)}
-
${lastA(x.r)?.start != null ? clock(run, lastA(x.r).start) : ''}
${elapsedOf(b, x.r) != null ? dur(elapsedOf(b, x.r)) : ''}
${Math.max(1, x.r.attempts.length)}
` +
${lastA(x.r)?.start != null ? clock(run, lastA(x.r).start) : ''}
${elapsedOf(b, x.r) != null ? dur(elapsedOf(b, x.r)) : ''}
${x.r.attempts.length}
` : `
${bubble(x.r.status)}${esc(x.path)}
`; const lead = cols ? '
#
Status
Script
Start
Elapsed
Att.
' : '
'; if (!run) return `

No run yet

${lead}
${rows.map(x => `${names(x)}
`).join('')}
`; diff --git a/tests/SqlRunboard.Gui.Tests/RunViewTests.cs b/tests/SqlRunboard.Gui.Tests/RunViewTests.cs index 11dc0e1..2069252 100644 --- a/tests/SqlRunboard.Gui.Tests/RunViewTests.cs +++ b/tests/SqlRunboard.Gui.Tests/RunViewTests.cs @@ -35,6 +35,7 @@ public async Task The_table_shows_the_run_in_queue_order() await Expect(Panel.Locator("table thead th")).ToHaveTextAsync(["#", "Status", "Script", "Start", "Elapsed", "Attempts", "Rows"]); await Expect(Rows.Locator("td:nth-child(3)")).ToHaveTextAsync(["a.sql", "b.sql", "c.sql"]); await Expect(Rows.Locator("td:nth-child(1)")).ToHaveTextAsync(["", "1", "2"]); // the queue position only for Queued scripts + await Expect(Rows.Locator("td:nth-child(6)")).ToHaveTextAsync(["1", "0", "0"]); // a script that did not start has 0 attempts App.Time.Advance(TimeSpan.FromSeconds(10)); await Expect(Rows.Nth(0)).ToContainTextAsync("Succeeded"); diff --git a/tests/SqlRunboard.Gui.Tests/TimelineTests.cs b/tests/SqlRunboard.Gui.Tests/TimelineTests.cs index f4f745e..3751b65 100644 --- a/tests/SqlRunboard.Gui.Tests/TimelineTests.cs +++ b/tests/SqlRunboard.Gui.Tests/TimelineTests.cs @@ -172,7 +172,7 @@ public async Task Columns_shows_a_table_with_a_Gantt_column_and_the_strip_and_ax await Toggle("Columns").CheckAsync(); await Expect(Panel.Locator(".tlg > .h")).ToHaveTextAsync(["#", "Status", "Script", "Start", "Elapsed", "Att."]); await Expect(Panel.Locator(".tlg > .c[data-path='a.sql']")).ToHaveTextAsync(["", "✓ Succeeded", "a.sql", "14:00:00", "0:00", "1"]); - await Expect(Panel.Locator(".tlg > .c[data-path='c.sql']")).ToHaveTextAsync(["1", "○ Queued", "c.sql", "", "", "1"]); + await Expect(Panel.Locator(".tlg > .c[data-path='c.sql']")).ToHaveTextAsync(["1", "○ Queued", "c.sql", "", "", "0"]); // not started: 0 attempts await Expect(Bars("b.sql")).ToHaveCountAsync(1); // the Gantt column // the strip and the axis are in the header of the chart column From 1d6637afe8a6a82d0f039702a9f50ebfb5af0f33 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:12:30 +0200 Subject: [PATCH 88/96] fix(gui): check the logon before a rerun during the run for a SQL Server logon --- src/SqlRunboard.Gui/wwwroot/index.html | 6 +++--- tests/SqlRunboard.Gui.Tests/LogonTests.cs | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 38702a6..1449427 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -323,9 +323,9 @@ // The server decides: during a run it moves a Queued script in the queue, otherwise it changes the start order. dir: up, down, top, bottom. const move = (b, path, dir) => path && sendEdit(`/api/batches/${b.id}/move`, { path, to: dir }); const moveBatch = (b, dir) => sendEdit(`/api/batches/${b.id}/move-in-list`, { to: dir }); -// A rerun after the run ended checks the logon first, as Run does. A rerun during the run uses the kept password, -// but "Confirm before run" also checks the logon then: the dialog shows the login. -const afterLogon = (b, n, go) => b.run?.ended || b.settings.confirmBeforeRun ? logon(b, confirmed(b, n, go)) : go(); +// A rerun after the run ended checks the logon first, as Run does. During the run, "Confirm before run" checks it too +// (the dialog shows the login), and so does a SQL Server logon: a logon failure can have made the server forget the password. +const afterLogon = (b, n, go) => b.run?.ended || b.settings.confirmBeforeRun || b.settings.auth === 'SqlServer' ? logon(b, confirmed(b, n, go)) : go(); const rerun = (b, path) => afterLogon(b, 1, () => sendEdit(`/api/batches/${b.id}/rerun`, { path })); const rerunFailed = b => afterLogon(b, Object.values(b.run.rs).filter(r => r.status === 'Failed' || r.status === 'Cancelled').length, () => sendEdit(`/api/batches/${b.id}/rerun-failed`, {})); diff --git a/tests/SqlRunboard.Gui.Tests/LogonTests.cs b/tests/SqlRunboard.Gui.Tests/LogonTests.cs index c5f053e..66c10d4 100644 --- a/tests/SqlRunboard.Gui.Tests/LogonTests.cs +++ b/tests/SqlRunboard.Gui.Tests/LogonTests.cs @@ -100,6 +100,27 @@ public async Task A_script_that_fails_its_logon_asks_for_the_password_at_the_rer Assert.Equal(["secret", "again"], App.Executor.Calls.Select(c => c.Connection.Password)); } + [Fact] + public async Task A_rerun_during_the_run_after_a_logon_failure_asks_for_the_password() + { + File.WriteAllText(Path.Combine(scripts, "b.sql"), "select 1"); + App.Executor.Scripts["b.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(60) }; + await OpenBatchAsync(SqlLogon); + App.Executor.Scripts["a.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(10), Status = ScriptStatus.Failed, Error = "Login failed for user 'deploy'.", LogonFailed = true }; + var running = Panel.GetByRole(AriaRole.Region, new() { Name = "Running", Exact = true }).Locator(".card"); + await RunButton.ClickAsync(); + await SignInAsync("secret"); + await Expect(running).ToHaveCountAsync(2); + App.Time.Advance(TimeSpan.FromSeconds(10)); // a.sql fails its logon; b.sql runs on + + await Expect(running).ToHaveCountAsync(1); + await Header.GetByRole(AriaRole.Button, new() { Name = "Rerun all failed" }).ClickAsync(); + await SignInAsync("again"); + + await Expect(running).ToHaveCountAsync(2); + Assert.Equal(["secret", "secret", "again"], App.Executor.Calls.Select(c => c.Connection.Password)); + } + [Fact] public async Task Test_connection_shows_the_server_name_and_the_database() { From 2c9e8e208541c2025523f108f08d28b3f6f5ead0 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:13:02 +0200 Subject: [PATCH 89/96] fix(core): ignore the letter case of batch file paths on Windows and macOS --- src/SqlRunboard.Core/BatchList.cs | 8 +++++--- tests/SqlRunboard.Core.Tests/BatchFileTests.cs | 11 +++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index f6095f5..b5d1d37 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -38,7 +38,7 @@ public sealed class BatchList(ISqlExecutor executor, TimeProvider time, string? public void Restore(IEnumerable paths) { lock (gate) - foreach (var file in paths.Where(p => Path.IsPathFullyQualified(p) && BatchFile.HasExtension(p)).Distinct(StringComparer.Ordinal)) + foreach (var file in paths.Where(p => Path.IsPathFullyQualified(p) && BatchFile.HasExtension(p)).Distinct(FileNames)) { var b = new Batch(++lastId, BatchFile.NameOf(file), file, Dirty: false, new BatchSettings(), Scripts: []); try { b = LastRun(Load(b)); } @@ -50,6 +50,9 @@ public void Restore(IEnumerable paths) } } + /// Batch file paths: Windows and macOS file names ignore the letter case. Script paths in a batch do not (docs/SPEC.md "Batch"). + static readonly StringComparer FileNames = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + /// The server keeps the last 10,000 messages of each attempt. run.log has all of them. public const int MaxMessages = 10_000; @@ -199,8 +202,7 @@ public Batch Open(string path) var file = Path.GetFullPath(path); lock (gate) { - // ponytail: an ordinal path compare; the same file with another letter case on Windows or macOS gets a second entry. - if (batches.Find(b => b.File == file) is { } open) return open; + if (batches.Find(b => FileNames.Equals(b.File, file)) is { } open) return open; return Put(Load(new Batch(++lastId, BatchFile.NameOf(file), file, Dirty: false, new BatchSettings(), Scripts: []))); } } diff --git a/tests/SqlRunboard.Core.Tests/BatchFileTests.cs b/tests/SqlRunboard.Core.Tests/BatchFileTests.cs index dd49f69..c7747c7 100644 --- a/tests/SqlRunboard.Core.Tests/BatchFileTests.cs +++ b/tests/SqlRunboard.Core.Tests/BatchFileTests.cs @@ -155,6 +155,17 @@ public void Opening_a_file_that_is_in_the_list_gives_the_existing_entry() Assert.Single(sub.Snapshot); } + [Fact] + public void On_Windows_and_macOS_the_same_file_with_another_letter_case_gives_the_existing_entry() + { + Assert.SkipUnless(OperatingSystem.IsWindows() || OperatingSystem.IsMacOS(), "File names on Linux are case-sensitive."); + File.WriteAllText(Path.Combine(root, "deploy.sqlrunboard.json"), """{ "version": 1 }"""); + + var first = list.Open(Path.Combine(root, "deploy.sqlrunboard.json")); + + Assert.Equal(first.Id, list.Open(Path.Combine(root, "DEPLOY.sqlrunboard.json")).Id); + } + [Fact] public void Open_accepts_only_batch_files() { From 2b0d9e42fe21342caa9b46096d70a000569b57cf Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:13:46 +0200 Subject: [PATCH 90/96] fix: queue a missing file on Run, so it fails with File not found when it starts --- src/SqlRunboard.Core/BatchList.cs | 2 +- src/SqlRunboard.Gui/wwwroot/index.html | 4 ++-- tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index b5d1d37..5ab12ae 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -317,7 +317,7 @@ Batch Put(Batch b) // ponytail: a log folder that cannot be made (read-only folder) stops the Run with the IO error; run without logs if users need that. var log = logParent is null ? null : RunLog.Create(logParent, b.Name, time, text => Warn(id, text)); var run = runs[id] = shown[id] = new ActiveRun(time.GetUtcNow(), folder ?? "", b.Settings, log); - foreach (var s in scripts.Where(s => s.Include && s.State != ScriptState.Missing)) + foreach (var s in scripts.Where(s => s.Include)) // a missing file fails with "File not found" when it starts { run.Scripts[s.Path] = new ScriptRun(s.Path, ScriptStatus.Queued, []); run.Queue.Add(s.Path); diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index 1449427..e1bdcde 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -313,7 +313,7 @@ const confirmed = (b, n, go) => r => b.settings.confirmBeforeRun ? openDialog({ kind: 'confirm', b, login: r.login, n, then: go }) : go(); function runBatch(b) { if (!b || b.missing || locked(b)) return; - logon(b, confirmed(b, b.scripts.filter(s => s.include && s.state !== 'Missing').length, async () => { + logon(b, confirmed(b, b.scripts.filter(s => s.include).length, async () => { const sb = await sendEdit(`/api/batches/${b.id}/run`, {}); if (sb) { upsert(sb); b.tab = 'run'; render(); } })); @@ -417,7 +417,7 @@ } // one row per script: the run rows, or the Queued rows before the first run function rowsFor(b) { - if (!b.run) return b.scripts.filter(s => s.include && s.state !== 'Missing').map((s, i) => ({ path: s.path, r: { status: 'Queued', attempts: [], virtual: true }, q: i + 1 })); + if (!b.run) return b.scripts.filter(s => s.include).map((s, i) => ({ path: s.path, r: { status: 'Queued', attempts: [], virtual: true }, q: i + 1 })); return Object.entries(b.run.rs).map(([path, r]) => ({ path, r, q: r.status === 'Queued' ? b.run.queue.indexOf(path) + 1 : null })); } function runOrder(b) { // start of the first attempt; Queued at the bottom in queue order diff --git a/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs b/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs index 291b70e..82a30f3 100644 --- a/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs +++ b/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs @@ -80,7 +80,8 @@ public void A_new_file_is_marked_New_and_a_missing_file_stays_with_a_warning_unt var scanned = list.Run(b.Id)!; Assert.Equal([new("a.sql", true, ScriptState.Missing), new("b.sql", true), new("c.sql", true, ScriptState.New), new("d.sql", true, ScriptState.New)], scanned.Scripts); - Assert.Equal(["b.sql", "c.sql", "d.sql"], scanned.Run!.Scripts.Select(s => s.Path)); // a missing file does not run + Assert.Equal(["a.sql", "b.sql", "c.sql", "d.sql"], scanned.Run!.Scripts.Select(s => s.Path)); // Run queues all included scripts + Assert.Equal((ScriptStatus.Failed, "File not found"), (scanned.Run.Scripts[0].Status, scanned.Run.Scripts[0].Attempts.Single().Error)); var saved = list.Save(b.Id, null, overwrite: false)!; From 32643fa1fdbcc20681c77d7b2bcd4fa0ebb8eec4 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:14:27 +0200 Subject: [PATCH 91/96] fix(core): keep a script Queued for a rerun in summary.csv, so a restart shows it Cancelled --- src/SqlRunboard.Core/RunLog.cs | 8 +++++-- tests/SqlRunboard.Core.Tests/RestoreTests.cs | 23 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/SqlRunboard.Core/RunLog.cs b/src/SqlRunboard.Core/RunLog.cs index 49ec48c..abd85ac 100644 --- a/src/SqlRunboard.Core/RunLog.cs +++ b/src/SqlRunboard.Core/RunLog.cs @@ -134,8 +134,12 @@ void Row(string path, int n, ScriptStatus status, Attempt? a, string? error) => a?.End is { } e ? (e - a.Start).TotalSeconds.ToString("F3", CultureInfo.InvariantCulture) : "", a?.Rows?.ToString(CultureInfo.InvariantCulture) ?? "", Csv(error), Csv(target.Server), Csv(target.Database)]).Append("\r\n"); foreach (var s in scripts) - if (s.Attempts.Count == 0) Row(s.Path, 0, s.Status, null, s.Error); - else for (var i = 0; i < s.Attempts.Count; i++) Row(s.Path, i + 1, s.Attempts[i].Status, s.Attempts[i], s.Attempts[i].Error); + { + for (var i = 0; i < s.Attempts.Count; i++) Row(s.Path, i + 1, s.Attempts[i].Status, s.Attempts[i], s.Attempts[i].Error); + // Work that did not start gets an attempt 0 row: a script that never started, or one that is Queued for a rerun + // (or was then cancelled). ReadSummary takes the status of the script from it. + if (s.Attempts.Count == 0 || s.Status != s.Attempts[^1].Status || s.Error is not null) Row(s.Path, 0, s.Status, null, s.Error); + } var file = Path.Combine(folder, SummaryFile); try diff --git a/tests/SqlRunboard.Core.Tests/RestoreTests.cs b/tests/SqlRunboard.Core.Tests/RestoreTests.cs index 9976a1f..0b60cc0 100644 --- a/tests/SqlRunboard.Core.Tests/RestoreTests.cs +++ b/tests/SqlRunboard.Core.Tests/RestoreTests.cs @@ -174,6 +174,29 @@ public async Task A_rerun_after_a_restart_publishes_its_messages() Assert.Equal(("2.sql", 2, "again"), (((ScriptMessage)c.Data).Path, ((ScriptMessage)c.Data).Attempt, ((ScriptMessage)c.Data).Text)); } + [Fact] + public async Task A_script_queued_for_a_rerun_comes_back_Cancelled_and_Rerun_all_failed_continues_it() + { + var a = Saved("a", null, "1.sql", "2.sql"); + executor.Scripts["2.sql"] = new FakeScript { Duration = TimeSpan.FromSeconds(100) }; + list.Run(a.Id); + await Until(a.Id, r => r.Scripts[1].Status == ScriptStatus.Running); + list.Rerun(a.Id, "1.sql"); // Queued after 2.sql: the parallel limit is 1 + + var again = Restart(); + using var sub2 = again.Subscribe(); + var one = sub2.Snapshot[0].Run!.Scripts[0]; + Assert.Equal((ScriptStatus.Cancelled, "The app stopped", 1), (one.Status, one.Error, one.Attempts.Count)); + + executor.Scripts.Clear(); + again.RerunFailed(sub2.Snapshot[0].Id); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + RunState run; + do run = ((Batch)(await sub2.Changes.ReadAsync(cts.Token)).Data).Run!; + while (run.Active); + Assert.Equal([2, 2], run.Scripts.Select(s => s.Attempts.Count)); + } + [Fact] public async Task A_target_change_while_the_app_was_stopped_ends_the_run() { From 5ca6de0d84eaac149beafedf8c509bda0c0bbe77 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:14:57 +0200 Subject: [PATCH 92/96] fix(core): compare folders as full paths, so an end separator does not end the run --- src/SqlRunboard.Core/BatchList.cs | 6 +++++- tests/SqlRunboard.Core.Tests/RunTests.cs | 14 +++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index 5ab12ae..a578fdc 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -108,10 +108,14 @@ public Batch NewBatch() var (a, run) = (b.Settings, b.Run); var newTarget = a.Target with { PasswordVariable = null } != settings.Target with { PasswordVariable = null }; if (newTarget) passwords.Remove(b.Id); // a new target asks for the password again - var ends = newTarget || (a.Folder, a.Filters, a.Recurse) != (settings.Folder, settings.Filters, settings.Recurse); + var ends = newTarget || Folder(b.File, a.Folder) != Folder(b.File, settings.Folder) || (a.Filters, a.Recurse) != (settings.Filters, settings.Recurse); return ends && run is { Ended: false, Active: false } && runs.Remove(b.Id) ? run with { Ended = true } : run; } + /// The full folder path, to compare: "scripts", "scripts/" and "./scripts" are the same folder. + static string? Folder(string? batchFile, string? folder) => + FolderPath(batchFile, folder) is { } f && !string.IsNullOrWhiteSpace(f) ? Path.TrimEndingDirectorySeparator(Path.GetFullPath(f)) : null; + /// Sets or clears the check box of one file. Null if no batch has this id. public Batch? SetInclude(int id, string path, bool include) { diff --git a/tests/SqlRunboard.Core.Tests/RunTests.cs b/tests/SqlRunboard.Core.Tests/RunTests.cs index c646540..85de861 100644 --- a/tests/SqlRunboard.Core.Tests/RunTests.cs +++ b/tests/SqlRunboard.Core.Tests/RunTests.cs @@ -341,7 +341,7 @@ public async Task A_change_to_the_target_or_the_file_scan_ends_the_run_and_rerun "user" => s with { Target = t with { User = "other" } }, "encrypt" => s with { Target = t with { Encrypt = Encrypt.Strict } }, "extra" => s with { Target = t with { ExtraConnectionString = "App=x" } }, - "folder" => s with { Folder = root + Path.DirectorySeparatorChar }, + "folder" => s with { Folder = Path.Combine(root, "other") }, "filters" => s with { Filters = "*.txt" }, _ => s with { Recurse = true }, })!.Run!; @@ -355,6 +355,18 @@ public async Task A_change_to_the_target_or_the_file_scan_ends_the_run_and_rerun Assert.All(next.Scripts, x => Assert.Single(x.Attempts)); } + [Fact] + public async Task The_same_folder_with_an_end_separator_does_not_end_the_run() + { + var b = NewBatch(4, "a.sql"); + list.Run(b.Id); + await Until(b.Id, r => !r.Active); + + var run = list.UpdateSettings(b.Id, b.Settings with { Folder = root + Path.DirectorySeparatorChar })!.Run!; + + Assert.False(run.Ended); + } + [Fact] public async Task Load_again_is_refused_while_the_run_has_Queued_or_Running_scripts() { From 90a4fa09895b50254e731803a86c9a2ac1dc66ea Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:16:44 +0200 Subject: [PATCH 93/96] refactor(gui): remove the script source viewer and the dead prototype code --- src/SqlRunboard.Gui/wwwroot/index.html | 62 +++++++------------------- 1 file changed, 15 insertions(+), 47 deletions(-) diff --git a/src/SqlRunboard.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html index e1bdcde..8e2663d 100644 --- a/src/SqlRunboard.Gui/wwwroot/index.html +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -2,7 +2,6 @@ @@ -125,18 +124,6 @@ .msgs .Error { color: var(--red); } .msgs .RowCount { color: var(--muted); } -.src { margin: 0; font: 12px/1.5 ui-monospace, Menlo, Consolas, monospace; white-space: pre; overflow: auto; tab-size: 4; } -.src div { padding-right: 12px; } -.src .ln { display: inline-block; width: 4ch; margin-right: 12px; text-align: right; color: var(--muted); user-select: none; } -.src .go { color: var(--sel); font-weight: 600; } -.src .cm { color: var(--muted); } -.src .hl { background: color-mix(in srgb, var(--red) 18%, transparent); } -.files { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; } -.preview { border: 1px solid var(--line); border-radius: 8px; overflow: hidden; } -.preview h3 { margin: 0; padding: 6px 10px; font-size: 12px; border-bottom: 1px solid var(--line); background: var(--panel); } -.preview .src { max-height: 60vh; padding: 6px 0; } -#drawer .src { flex: 1; padding: 8px 0; } -a[data-line] { color: inherit; text-decoration: underline; } .form { display: grid; grid-template-columns: 190px minmax(0, 360px); gap: 8px 12px; align-items: center; } .form h3 { grid-column: 1 / -1; margin: 12px 0 0; font-size: 13px; } .form .hint { grid-column: 2; font-size: 12px; color: var(--muted); margin-top: -4px; } @@ -165,7 +152,7 @@ #drawer-resize { display: none; } #drawer { width: auto !important; border-left: 0; border-top: 1px solid var(--line); max-height: 45vh; } .board { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .form, .files { grid-template-columns: 1fr; } .form .hint { grid-column: 1; } + .form { grid-template-columns: 1fr; } .form .hint { grid-column: 1; } #panel { overflow-x: auto; } } @@ -219,7 +206,7 @@ const t0 = Date.parse(r.start), sec = x => x == null ? null : (Date.parse(x) - t0) / 1000, skew = Date.parse(r.now) - Date.now(), rs = {}; for (const s of r.scripts) rs[s.path] = { status: s.status, error: s.error, attempts: s.attempts.map((a, i) => ({ n: i + 1, start: sec(a.start), end: sec(a.end), status: a.status, rows: a.rows, error: a.error })) }; const ended = !r.scripts.some(s => s.status === 'Queued' || s.status === 'Running'); - return { rs, queue: r.queue, wall: new Date(t0), live: true, ended, stale: r.ended, endAt: ended ? Math.max(0, ...Object.values(rs).flatMap(x => x.attempts.map(a => a.end ?? 0))) : null, get t() { return (Date.now() + skew - t0) / 1000; } }; + return { rs, queue: r.queue, wall: new Date(t0), ended, stale: r.ended, endAt: ended ? Math.max(0, ...Object.values(rs).flatMap(x => x.attempts.map(a => a.end ?? 0))) : null, get t() { return (Date.now() + skew - t0) / 1000; } }; } const fromView = s => ({ folder: s.folder || null, filters: s.filters, recurse: s.recurse, parallelLimit: +s.parallelLimit, stopOnFirstFailure: s.stopOnFirstFailure, confirmBeforeRun: s.confirmBeforeRun, timeoutSeconds: s.timeoutSeconds === '' ? null : +s.timeoutSeconds, @@ -282,11 +269,9 @@ const lastA = r => r.attempts[r.attempts.length - 1]; const firstStart = r => Math.min(...r.attempts.filter(a => a.start != null).map(a => a.start)); const canMove = (b, path) => b.run ? !b.run.ended && b.run.rs[path]?.status === 'Queued' : !locked(b) && !!b.scripts.find(x => x.path === path); -const locked = b => !!b.signing || !!(b.run && b.run.live && !b.run.ended); +const locked = b => !!b.signing || !!(b.run && !b.run.ended); const canRerun = (b, r) => DONE.includes(r.status) && !b.signing && !b.run.stale; // stale: the settings changed, so the run ended -// ponytail: stand-ins for the commands of later tickets. Each ticket replaces its names with a POST. -const todo = () => toast('Not available yet.'); // Edits go to the server one at a time, in the order you make them. queued() gives the error to the caller; sendEdit() shows it. let edits = Promise.resolve(); function queued(url, body) { const p = edits.then(() => post(url, body)); edits = p.catch(() => null); return p; } @@ -377,12 +362,6 @@ if (app.cur === id) app.cur = (app.batches[i] ?? app.batches[i - 1])?.id ?? null; render(); } -const scriptText = () => null; // the file text comes with the Files tab -function renderSource(lines, hl) { - if (!lines) return '

The file is not on disk.

'; - return `
${lines.map((t, i) => `
${i + 1}${esc(t) || ' '}
`).join('')}
`; -} -const lineLink = text => esc(text).replace(/^line (\d+)/, (m, n) => `${m}`); // ---------- view helpers ---------- const ICON = { NotRun: '○', Queued: '○', Running: '▶', Succeeded: '✓', Failed: '✕', Cancelled: '✕' }; @@ -390,11 +369,9 @@ const esc = x => String(x ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const dur = sec => { sec = Math.max(0, Math.floor(sec)); const h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60, p = n => String(n).padStart(2, '0'); return h ? `${h}:${p(m)}:${p(s)}` : `${m}:${p(s)}`; }; const words = sec => { sec = Math.floor(sec); const m = Math.floor(sec / 60), s = sec % 60; return [m && `${m} minute${m > 1 ? 's' : ''}`, `${s} second${s !== 1 ? 's' : ''}`].filter(Boolean).join(' '); }; -const p2 = n => String(n).padStart(2, '0'); -const stamp = d => `${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}`; const clock = (run, sec) => new Date(run.wall.getTime() + sec * 1000).toLocaleTimeString([], { hour12: false }); const bubble = st => ``; -const aEnd = (run, a) => a.end ?? (a.status === 'Running' ? run.t : a.barEnd ?? null); +const aEnd = (run, a) => a.end ?? (a.status === 'Running' ? run.t : null); const elapsedOf = (b, r) => { const a = r && lastA(r); if (!a || a.start == null) return null; const e = aEnd(b.run, a); return e == null ? null : e - a.start; }; const rowsOf = r => { const a = r && lastA(r); return a?.rows ?? null; }; function counts(b) { @@ -427,7 +404,7 @@ return [...started, ...queued, ...rows.filter(x => !started.includes(x) && !queued.includes(x))]; } function boardCols(b) { - const rows = rowsFor(b), endKey = x => { const a = lastA(x.r); return a ? a.end ?? a.cancelAt ?? a.barEnd ?? 0 : 0; }; + const rows = rowsFor(b), endKey = x => { const a = lastA(x.r); return a?.end ?? 0; }; return [ ['Queued', rows.filter(x => x.r.status === 'Queued').sort((x, y) => x.q - y.q)], ['Running', rows.filter(x => x.r.status === 'Running').sort((x, y) => lastA(x.r).start - lastA(y.r).start)], @@ -484,7 +461,6 @@ let head = `
${['board', 'table', 'timeline'].map(v => ``).join('')}
`; if (app.view === 'timeline') head += ``; head += '
'; - if (run?.restored && !run.live) head += `

Last run, from logs/${esc(b.name)}_${stamp(run.wall)}/summary.csv.${run.unfinished ? ` Interrupted: the app stopped at or after ${clock(run, run.newest)}.` : ''} Rerun opens this run again. Run starts a new run.

`; if (run?.stale) head += '

The settings changed. Run the batch again.

'; if (!run) head += '

No run yet. These scripts are Queued in the start order. Move them here or in the Files tab.

'; return head + (app.view === 'board' ? renderBoard(b) : app.view === 'table' ? renderTable(b) : renderTimeline(b)); @@ -495,7 +471,6 @@ if (e != null) parts.push(dur(e)); parts.push(`attempt ${x.r.attempts.length}`, `${rows == null ? '—' : rows.toLocaleString()} rows`); if (x.r.status === 'Cancelled') parts.push('Cancelled'); - if (x.r.status === 'Running' && lastA(x.r).rollback != null) parts.push('Rolling back'); return parts.map(p => `${p}`).join(''); } function renderBoard(b) { @@ -544,8 +519,8 @@ const breaks = sc.gaps.map(g => `
`).join(''); const breakLabels = sc.gaps.map(g => `
⋯ ${dur(g.e - g.s)} idle
`).join(''); const bars = x => x.r.attempts.filter(a => a.start != null).map(a => { - const e = aEnd(run, a) ?? a.start, l = sc.f(a.start), w = sc.f(e) - l, d = dur(e - a.start), st = a.status, icon = a.error?.startsWith('Interrupted') ? '✕' : ICON[st]; - const tip = `Attempt ${a.n} ${st}\nStart ${clock(run, a.start)}\nEnd ${a.end != null ? clock(run, a.end) : st === 'Running' ? '(running)' : '(interrupted)'}\nDuration ${d}`; + const e = aEnd(run, a) ?? a.start, l = sc.f(a.start), w = sc.f(e) - l, d = dur(e - a.start), st = a.status, icon = ICON[st]; + const tip = `Attempt ${a.n} ${st}\nStart ${clock(run, a.start)}\nEnd ${a.end != null ? clock(run, a.end) : '(running)'}\nDuration ${d}`; return ``; }).join(''); @@ -566,13 +541,13 @@
Folder ${esc(b.settings.folder)} · Filters ${esc(b.settings.filters)} ${['top', 'up', 'down', 'bottom'].map(d => ``).join('')}
-
+
Run#FileState
${b.scripts.map((s, i) => ``).join('')}
Run#FileState
${i + 1}${esc(s.path)} ${s.state === 'New' ? 'New' : s.state === 'Missing' ? '⚠ Missing on disk. The next save removes it.' : ''}${s.include ? '' : ' skipped'}
-
${sel ? `

${esc(sel)}

${renderSource(scriptText(b, sel))}` : '

Select a file to see its content.

'}
`; +`; } function renderSettings(b) { const s = b.settings, lk = locked(b), dis = lk ? 'disabled' : ''; @@ -603,7 +578,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} if (!b || !b.drawer || !b.sel) return ''; const x = rowsFor(b).find(y => y.path === b.sel); if (!x) return ''; - const r = x.r, a = lastA(r), live = b.run && !b.run.ended, mv = canMove(b, x.path); + const r = x.r, live = b.run && !b.run.ended, mv = canMove(b, x.path); let body = b.warnings.map(w => `
${esc(w)}
`).join(''); // log write warnings of the run if (r.virtual) body = '

No run yet.

'; else if (!r.attempts.length && r.error) body = `
${esc(r.error)}
`; // did not start @@ -611,20 +586,19 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} body += `
Attempt ${at.n}${at.start != null ? ` · ${clock(b.run, at.start)}` : ''} · ${at.status}
`; const ms = b.msgs[x.path]?.[at.n]; if (ms?.earlier) body += `
${ms.earlier.toLocaleString()} earlier messages are in run.log
`; - body += (ms?.list ?? []).map(m => `
${new Date(m.time).toLocaleTimeString([], { hour12: false })} ${m.kind === 'Error' ? lineLink(m.text) : esc(m.text)}
`).join(''); + body += (ms?.list ?? []).map(m => `
${new Date(m.time).toLocaleTimeString([], { hour12: false })} ${esc(m.text)}
`).join(''); if (!ms && at.error) body += `
${esc(at.error)}
`; // a run from summary.csv after a restart has no messages } return `
${bubble(r.status)}${esc(x.path)}
- + ${['top', 'up', 'down', 'bottom'].map(d => ``).join('')} -
- ${b.dview === 'script' ? renderSource(scriptText(b, x.path), b.hlLine) : `
${body}
`} +
${body}

PRINT output can arrive late. For live progress, use RAISERROR('text', 0, 1) WITH NOWAIT.

`; } let lastLock = null; -const FOCUS_ATTRS = ['data-dview', 'data-k', 'data-cmd', 'data-tab', 'data-view', 'data-sort', 'data-tl', 'data-dmove', 'data-fmove', 'data-include', 'data-set']; +const FOCUS_ATTRS = ['data-k', 'data-cmd', 'data-tab', 'data-view', 'data-sort', 'data-tl', 'data-dmove', 'data-fmove', 'data-include', 'data-set']; function focusSel(el) { const scope = el?.closest?.('#side, #hdr, #tabs, #panel, #drawer, .topbar'); if (!scope) return null; const a = FOCUS_ATTRS.find(x => el.hasAttribute(x)); if (!a) return null; @@ -656,10 +630,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} if (!panelEl.querySelector('[data-k][tabindex="0"]')) panelEl.querySelector('[data-k^="s:"], [data-k^="f:"]')?.setAttribute('tabindex', '0'); // roving tab stop } lastLock = lk; - const srcTop = drawerEl.querySelector('.src')?.scrollTop; if (rebuild || liveView) { const dh = renderDrawer(b); drawerEl.hidden = !dh; drawerEl.innerHTML = dh; document.getElementById('drawer-resize').hidden = !dh; } - const src = drawerEl.querySelector('.src'); - if (src) { if (b.scrollLine) { src.querySelector('#L-' + b.scrollLine)?.scrollIntoView({ block: 'center' }); b.scrollLine = null; } else if (srcTop != null) src.scrollTop = srcTop; } const m = rebuild || liveView ? drawerEl.querySelector('.msgs') : null; if (m) { if (b.scrollAttempt) { m.querySelector('#att-' + b.scrollAttempt)?.scrollIntoView({ block: 'start' }); b.scrollAttempt = null; } @@ -734,7 +705,7 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} const el = document.getElementById('menu'); el.innerHTML = ``; el.querySelector('button:not(:disabled)')?.focus(); @@ -788,7 +759,6 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} case 'dCancel': cancel(b, b.sel); return render(); case 'dRerun': rerun(b, b.sel); return render(); case 'closeDrawer': b.drawer = false; render(); return document.querySelector(`[data-k="s:${CSS.escape(b.sel)}"]`)?.focus(); - case 'runlog': return todo(); case 'test': return logon(b, r => { b.testMsg = `Connected. Server ${r.server}, database ${r.database}, login ${r.login}.`; }); case 'browse': return pickFolder(b); case 'sortFiles': return sendEdit(`/api/batches/${b.id}/sort`, {}); @@ -816,8 +786,6 @@

Target

${txt('server', 'Server')}${txt('database', 'Database')} const tab = t.closest('[data-tab]'); if (tab) { b.tab = tab.dataset.tab; return render(); } const v = t.closest('[data-view]'); if (v) { app.view = v.dataset.view; store.set('view', app.view); return render(); } const so = t.closest('[data-sort]'); if (so) { const k = so.dataset.sort; b.sort = b.sort?.key === k ? (b.sort.dir > 0 ? { key: k, dir: -1 } : null) : { key: k, dir: 1 }; return render(); } - const dv = t.closest('[data-dview]'); if (dv) { b.dview = dv.dataset.dview; b.hlLine = null; return render(); } - const ln = t.closest('[data-line]'); if (ln) { e.preventDefault(); b.dview = 'script'; b.hlLine = b.scrollLine = +ln.dataset.line; return render(); } const dm = t.closest('[data-dmove]'); if (dm) { move(b, b.sel, dm.dataset.dmove); return render(); } const fm = t.closest('[data-fmove]'); if (fm) return move(b, b.fileSel, fm.dataset.fmove); if (t.matches('[data-include]')) return; From 44ecb25ea7c17b644e45d250c29bf034c83f6019 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:17:39 +0200 Subject: [PATCH 94/96] refactor(gui): answer 200 or 404 for a batch command through one helper --- src/SqlRunboard.Gui/GuiHost.cs | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs index 38f56fa..c3c6e94 100644 --- a/src/SqlRunboard.Gui/GuiHost.cs +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -86,27 +86,27 @@ public static async Task StartAsync(GuiHostOptions options) app.MapPost("/api/batches", (BatchList list) => list.NewBatch()); app.MapPost("/api/batches/{id:int}/settings", (int id, BatchSettings settings, BatchList list) => !settings.IsValid ? Results.BadRequest() - : list.UpdateSettings(id, settings) is { } b ? Results.Ok(b) : Results.NotFound()); + : Found(list.UpdateSettings(id, settings))); app.MapPost("/api/batches/{id:int}/include", (int id, IncludeRequest r, BatchList list) => - list.SetInclude(id, r.Path, r.Include) is { } b ? Results.Ok(b) : Results.NotFound()); + Found(list.SetInclude(id, r.Path, r.Include))); app.MapPost("/api/batches/{id:int}/logon", async (int id, LogonRequest r, BatchList list, CancellationToken ct) => - await list.LogonAsync(id, r.Password, ct) is { } logon ? Results.Ok(logon) : Results.NotFound()); + Found(await list.LogonAsync(id, r.Password, ct))); app.MapPost("/api/batches/{id:int}/run", (int id, BatchList list) => - list.Run(id) is { } b ? Results.Ok(b) : Results.NotFound()); + Found(list.Run(id))); app.MapPost("/api/batches/{id:int}/cancel", (int id, ScriptRequest r, BatchList list) => - list.Cancel(id, r.Path) is { } b ? Results.Ok(b) : Results.NotFound()); + Found(list.Cancel(id, r.Path))); app.MapPost("/api/batches/{id:int}/cancel-all", (int id, BatchList list) => - list.CancelAll(id) is { } b ? Results.Ok(b) : Results.NotFound()); + Found(list.CancelAll(id))); app.MapPost("/api/batches/{id:int}/move", (int id, MoveRequest r, BatchList list) => - list.Move(id, r.Path, r.To) is { } b ? Results.Ok(b) : Results.NotFound()); + Found(list.Move(id, r.Path, r.To))); app.MapPost("/api/batches/{id:int}/sort", (int id, BatchList list) => - list.SortByFileName(id) is { } b ? Results.Ok(b) : Results.NotFound()); + Found(list.SortByFileName(id))); app.MapPost("/api/batches/{id:int}/move-in-list", (int id, MoveBatchRequest r, BatchList list) => list.MoveBatch(id, r.To) ? Results.Ok(new { }) : Results.NotFound()); app.MapPost("/api/batches/{id:int}/rerun", (int id, ScriptRequest r, BatchList list) => - list.Rerun(id, r.Path) is { } b ? Results.Ok(b) : Results.NotFound()); + Found(list.Rerun(id, r.Path))); app.MapPost("/api/batches/{id:int}/rerun-failed", (int id, BatchList list) => - list.RerunFailed(id) is { } b ? Results.Ok(b) : Results.NotFound()); + Found(list.RerunFailed(id))); app.MapPost("/api/batches/open", (PathRequest r, BatchList list) => FileResult(() => list.Open(r.Path))); app.MapPost("/api/batches/{id:int}/save", (int id, SaveRequest r, BatchList list) => FileResult(() => list.Save(id, r.Path, r.Overwrite))); app.MapPost("/api/batches/{id:int}/reload", (int id, BatchList list) => FileResult(() => list.Reload(id))); @@ -175,10 +175,13 @@ static void RestoreBatches(BatchList list, AppSettings settings, ILogger log) }; } + /// 200 with the result, or 404 when no batch has the id. + static IResult Found(object? result) => result is null ? Results.NotFound() : Results.Ok(result); + /// A batch file command: the batch, or a problem whose detail the page shows. 409: the file changed on disk, or the run locks the batch. static IResult FileResult(Func command) { - try { return command() is { } b ? Results.Ok(b) : Results.NotFound(); } + try { return Found(command()); } catch (Exception e) when (e is BatchFileChangedException or InvalidOperationException) { return Results.Problem(e.Message, statusCode: StatusCodes.Status409Conflict); } catch (Exception e) when (e is FileNotFoundException or DirectoryNotFoundException) { return Results.Problem(e.Message, statusCode: StatusCodes.Status404NotFound); } catch (Exception e) when (e is ArgumentException or InvalidDataException) { return Results.Problem(e.Message, statusCode: StatusCodes.Status400BadRequest); } From bbe9700262ac3a309d8f537146888d6c9cf7df7e Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:18:07 +0200 Subject: [PATCH 95/96] test(gui): start the executable in its tests through one helper --- .../SqlRunboard.Gui.Tests/ExecutableTests.cs | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs b/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs index c4c186a..4710b22 100644 --- a/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs +++ b/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs @@ -5,6 +5,15 @@ public class ExecutableTests { static readonly CancellationToken Ct = TestContext.Current.CancellationToken; + /// bin/sqlrunboard.dll with these arguments, through the dotnet host of the test run. Standard output is redirected. + static ProcessStartInfo Exe(params string[] args) + { + var psi = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") { RedirectStandardOutput = true }; + psi.ArgumentList.Add(Path.Combine(AppContext.BaseDirectory, "sqlrunboard.dll")); + foreach (var a in args) psi.ArgumentList.Add(a); + return psi; + } + [Fact] public void The_browser_executable_has_no_native_UI_dependency() { @@ -18,12 +27,7 @@ public void The_browser_executable_has_no_native_UI_dependency() public async Task Starts_the_server_in_the_given_app_data_folder_without_a_browser_and_prints_the_start_url() { var appData = Path.Combine(Path.GetTempPath(), "sqlrunboard-exe-" + Guid.NewGuid().ToString("N")); - var psi = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") - { - ArgumentList = { Path.Combine(AppContext.BaseDirectory, "sqlrunboard.dll"), "--app-data", appData, "--no-browser" }, - RedirectStandardOutput = true, - }; - using var exe = Process.Start(psi)!; + using var exe = Process.Start(Exe("--app-data", appData, "--no-browser"))!; try { var line = await exe.StandardOutput.ReadLineAsync(Ct).AsTask().WaitAsync(TimeSpan.FromSeconds(30), Ct) ?? ""; @@ -49,12 +53,7 @@ public async Task Ctrl_C_stops_the_server_and_removes_the_lock_file() { Assert.SkipWhen(OperatingSystem.IsWindows(), "No SIGINT for a child process on Windows. Quit and Ctrl+C share the ApplicationStopping handler (QuitTests)."); var appData = Directory.CreateTempSubdirectory("sqlrunboard-exe-").FullName; - var psi = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") - { - ArgumentList = { Path.Combine(AppContext.BaseDirectory, "sqlrunboard.dll"), "--app-data", appData, "--no-browser" }, - RedirectStandardOutput = true, - }; - using var exe = Process.Start(psi)!; + using var exe = Process.Start(Exe("--app-data", appData, "--no-browser"))!; try { await exe.StandardOutput.ReadLineAsync(Ct).AsTask().WaitAsync(TimeSpan.FromSeconds(30), Ct); @@ -77,12 +76,7 @@ public async Task Ctrl_C_stops_the_server_and_removes_the_lock_file() public async Task A_second_start_opens_the_running_server_and_stops() { await using var app = await TestApp.StartAsync(); - var psi = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") - { - ArgumentList = { Path.Combine(AppContext.BaseDirectory, "sqlrunboard.dll"), "--app-data", app.AppData, "--no-browser" }, - RedirectStandardOutput = true, - }; - using var exe = Process.Start(psi)!; + using var exe = Process.Start(Exe("--app-data", app.AppData, "--no-browser"))!; try { var output = await exe.StandardOutput.ReadToEndAsync(Ct).WaitAsync(TimeSpan.FromSeconds(30), Ct); @@ -100,11 +94,8 @@ public async Task A_second_start_opens_the_running_server_and_stops() [Fact] public async Task An_unknown_argument_prints_the_usage_and_exits_with_code_2() { - var psi = new ProcessStartInfo(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") ?? "dotnet") - { - ArgumentList = { Path.Combine(AppContext.BaseDirectory, "sqlrunboard.dll"), "--bogus" }, - RedirectStandardError = true, - }; + var psi = Exe("--bogus"); + psi.RedirectStandardError = true; using var exe = Process.Start(psi)!; try { From 7e99e264590ba352ebda56beb2dd2def24404d02 Mon Sep 17 00:00:00 2001 From: Hank <36976296+ravenbix@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:21:27 +0200 Subject: [PATCH 96/96] refactor(core): move the run of a batch into RunEngine, which shares the BatchList lock --- src/SqlRunboard.Core/BatchList.cs | 191 +++++------------------------- src/SqlRunboard.Core/RunEngine.cs | 188 +++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 162 deletions(-) create mode 100644 src/SqlRunboard.Core/RunEngine.cs diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs index a578fdc..ebcf257 100644 --- a/src/SqlRunboard.Core/BatchList.cs +++ b/src/SqlRunboard.Core/BatchList.cs @@ -8,20 +8,19 @@ public sealed record Change(string Kind, object Data); /// /// The side list: all batches, in the order you added them. The server keeps this state; the page shows it. /// Each subscriber gets the full state, then every change after it. -/// It also runs the batches: each batch has its own queue and parallel limit, and many batches can run at once. +/// It also runs the batches: the current run of each batch is a RunEngine with its own queue and parallel limit, and many batches can run at once. /// Each run writes a log folder next to the batch file, or for an unsaved batch in appDataFolder (no log if it is null). /// public sealed class BatchList(ISqlExecutor executor, TimeProvider time, string? appDataFolder = null) { readonly Lock gate = new(); readonly List batches = []; - readonly Dictionary runs = []; - readonly Dictionary shown = []; // the run that each batch shows: its messages stay when the run ends + readonly Dictionary runs = []; // the current run of each batch + readonly Dictionary shown = []; // the run that each batch shows: its messages stay when the run ends readonly List> subscribers = []; readonly Dictionary passwords = []; // in memory only: never in a Batch, a file or an event readonly Dictionary loaded = []; // batch id -> the file text that the app last read or wrote readonly HashSet finishing = []; // one per Running script: ends after Finish wrote its state and log - List? held; // while Pump starts executors: their first messages, to publish after the run state int lastId, lastUntitled; long version; string[] files = []; @@ -134,7 +133,7 @@ public Batch NewBatch() if (Find(id) is not { } b) return null; if (b.Run?.Active == true) { - if (runs.GetValueOrDefault(id) is { } run && MoveIn(run.Queue, p => p == path, to)) Pump(id, run); + runs.GetValueOrDefault(id)?.Move(path, to); return Find(id); } var scripts = b.Scripts.ToList(); @@ -161,7 +160,7 @@ public bool MoveBatch(int id, MoveTo to) } /// Moves the first match. False if there is no match or it is already at that end. - static bool MoveIn(List list, Predicate match, MoveTo to) + internal static bool MoveIn(List list, Predicate match, MoveTo to) { var i = list.FindIndex(match); if (i < 0) return false; @@ -251,8 +250,8 @@ Batch LastRun(Batch b) var ended = scripts.Count > 0 && (server != (target.Server ?? "") || database != (target.Database ?? "")); if (!ended) { - var run = runs[b.Id] = shown[b.Id] = new ActiveRun(start, FolderPath(b.File, b.Settings.Folder) ?? "", b.Settings, RunLog.Open(folder, time, text => Warn(b.Id, text))); - foreach (var s in scripts) run.Scripts[s.Path] = s; + var run = runs[b.Id] = shown[b.Id] = new RunEngine(this, b.Id, start, FolderPath(b.File, b.Settings.Folder) ?? "", b.Settings, RunLog.Open(folder, time, text => Warn(b.Id, text))); + run.Restore(scripts); } return b with { Run = new RunState(start, time.GetUtcNow(), scripts, [], ended) }; } @@ -320,13 +319,8 @@ Batch Put(Batch b) var logParent = b.File is null ? appDataFolder : Path.GetDirectoryName(b.File); // ponytail: a log folder that cannot be made (read-only folder) stops the Run with the IO error; run without logs if users need that. var log = logParent is null ? null : RunLog.Create(logParent, b.Name, time, text => Warn(id, text)); - var run = runs[id] = shown[id] = new ActiveRun(time.GetUtcNow(), folder ?? "", b.Settings, log); - foreach (var s in scripts.Where(s => s.Include)) // a missing file fails with "File not found" when it starts - { - run.Scripts[s.Path] = new ScriptRun(s.Path, ScriptStatus.Queued, []); - run.Queue.Add(s.Path); - } - Pump(id, run); + var run = runs[id] = shown[id] = new RunEngine(this, id, time.GetUtcNow(), folder ?? "", b.Settings, log); + run.Start(scripts.Where(s => s.Include).Select(s => s.Path)); // a missing file fails with "File not found" when it starts return Find(id); } } @@ -338,21 +332,15 @@ Batch Put(Batch b) public Batch? Cancel(int id, string path) => Cancel(id, _ => [path]); /// Cancels all Running and Queued scripts of the current run. Null if no batch has this id. - public Batch? CancelAll(int id) => Cancel(id, run => [.. run.Queue, .. run.Tokens.Keys]); + public Batch? CancelAll(int id) => Cancel(id, run => run.Active); - Batch? Cancel(int id, Func> paths) + Batch? Cancel(int id, Func> paths) { List stop = []; Batch? batch; lock (gate) { - if (runs.GetValueOrDefault(id) is { } run) - { - foreach (var path in paths(run)) - if (run.Queue.Remove(path)) run.Scripts[path] = run.Scripts[path] with { Status = ScriptStatus.Cancelled }; - else if (run.Tokens.TryGetValue(path, out var cts) && run.Cancelled.Add(path)) stop.Add(cts); - Pump(id, run); - } + if (runs.GetValueOrDefault(id) is { } run) stop = run.Cancel(paths(run)); batch = Find(id); } // Outside the lock: a real executor can block while it tells the server to stop. @@ -378,152 +366,31 @@ public Task StopAsync() /// Reruns all Failed and Cancelled scripts of the current run, in the start order. Null if no batch has this id. public Batch? RerunFailed(int id) => - Rerun(id, run => [.. run.Scripts.Values.Where(s => s.Status is ScriptStatus.Failed or ScriptStatus.Cancelled).Select(s => s.Path)]); + Rerun(id, run => run.FailedOrCancelled); - // Reruns ignore the check boxes. They add attempts to the current run. - Batch? Rerun(int id, Func> paths) + Batch? Rerun(int id, Func> paths) { lock (gate) { - if (runs.GetValueOrDefault(id) is { } run && Find(id) is { } batch) - { - run.Settings = batch.Settings with { Target = run.Settings.Target }; // reruns use the current parallel limit, stop setting and timeout - foreach (var path in paths(run)) - if (run.Scripts.TryGetValue(path, out var s) && s.Status is ScriptStatus.Succeeded or ScriptStatus.Failed or ScriptStatus.Cancelled) - { - run.Scripts[path] = s with { Status = ScriptStatus.Queued, Error = null }; - run.Queue.Add(path); - } - Pump(id, run); - } + if (runs.GetValueOrDefault(id) is { } run && Find(id) is { } batch) run.Rerun(paths(run), batch.Settings); return Find(id); } } - /// The mutable state of a run. Change it only under the lock. - sealed class ActiveRun(DateTimeOffset start, string folder, BatchSettings settings, RunLog? log) + // The run engines share the lock and call these under it. + internal Lock Gate => gate; + internal TimeProvider Time => time; + internal ISqlExecutor Executor => executor; + internal HashSet Finishing => finishing; + internal string? Password(int id) => passwords.GetValueOrDefault(id); + internal void ForgetPassword(int id) => passwords.Remove(id); + /// Stores and publishes the state of the run, if it is the current run of the batch. + internal void Show(int id, RunEngine run) { - public RunLog? Log => log; - public DateTimeOffset Start => start; - public string Folder => folder; - public BatchSettings Settings { get; set; } = settings; // a rerun sets the current settings; they cannot change while scripts run - public OrderedDictionary Scripts { get; } = new(StringComparer.Ordinal); // the start order - public List Queue { get; } = []; - public int Running { get; set; } - public HashSet Cancelled { get; } = new(StringComparer.Ordinal); // Running scripts that you cancelled: not a timeout - public Dictionary Tokens { get; } = new(StringComparer.Ordinal); // one per Running script - public Dictionary<(string Path, int Attempt), (Queue Kept, int Earlier)> Messages { get; } = []; - - public void Add(string path, Attempt a) { var s = Scripts[path]; Scripts[path] = s with { Status = a.Status, Attempts = [.. s.Attempts, a] }; } - /// "Stop the queue at the first failure": no new scripts start. The Running scripts continue. - public void Failed() - { - if (!Settings.StopOnFirstFailure) return; - foreach (var path in Queue) Scripts[path] = Scripts[path] with { Status = ScriptStatus.Cancelled, Error = "Not started: the queue stopped" }; - Queue.Clear(); - } - public void SetLast(string path, Attempt a) { var s = Scripts[path]; Scripts[path] = s with { Status = a.Status, Attempts = [.. s.Attempts.SkipLast(1), a] }; } + if (runs.GetValueOrDefault(id) == run && Find(id) is { } b) Put(b with { Run = run.State }); } - - // Call under the lock. Starts Queued scripts up to the parallel limit, then publishes the run. - // The executors start before the publish, so a Running script that a subscriber sees has started. - // Their messages wait until after the publish, so the page sees an attempt before its first message. - void Pump(int id, ActiveRun run) - { - List starts = []; - while (run.Running < run.Settings.ParallelLimit && run.Queue.Count > 0) - { - var path = run.Queue[0]; - run.Queue.RemoveAt(0); - var start = time.GetUtcNow(); - IReadOnlyList parts; - // Each script reads its file when it starts, so you can fix a Queued script. - // ponytail: the read runs under the lock; move it out if slow network folders block the page. - try { parts = SqlScript.Read(Path.Combine(run.Folder, path)); } - catch (Exception e) when (e is IOException or UnauthorizedAccessException) - { - var error = e is FileNotFoundException or DirectoryNotFoundException ? "File not found" : e.Message; - run.Add(path, new Attempt(start, start, ScriptStatus.Failed, null, error)); - run.Log?.Append($"{Attempt(run, path)}: Failed: {error}"); - var n = run.Scripts[path].Attempts.Count; - starts.Add(() => AddMessage(id, run, path, n, new(SqlMessageKind.Error, error))); - run.Failed(); - continue; - } - run.Running++; - run.Add(path, new Attempt(start, null, ScriptStatus.Running, null, null)); - var (attempt, number) = (Attempt(run, path), run.Scripts[path].Attempts.Count); - run.Log?.Append($"{attempt}: Started"); - var execution = new SqlExecution(path, parts, new SqlConnectionSettings(run.Settings.Target, passwords.GetValueOrDefault(id))); - // The continuation runs on the thread pool, never inline here under the lock. - var cts = run.Tokens[path] = run.Settings.TimeoutSeconds is int s ? new CancellationTokenSource(TimeSpan.FromSeconds(s), time) : new CancellationTokenSource(); - void OnMessage(SqlMessage m) - { - run.Log?.Append($"{attempt}: {(m.Kind == SqlMessageKind.Error ? "Error: " : "")}{m.Text}"); // outside the lock: a file write - lock (gate) AddMessage(id, run, path, number, m); - } - starts.Add(() => - { - Task task; - try { task = executor.ExecuteAsync(execution, OnMessage, cts.Token); } - catch (Exception e) { task = Task.FromException(e); } // a throw before the first await fails the attempt too - Task? done = null; - // The continuation takes the lock, so it cannot remove 'done' before this method adds it. - done = task.ContinueWith(t => - { - try { Finish(id, run, path, start, t); } - finally { lock (gate) finishing.Remove(done!); } - }, TaskScheduler.Default); - finishing.Add(done); - }); - } - var outer = held; - held = []; - foreach (var a in starts) a(); - var messages = held; - held = outer; - run.Log?.Update(run.Scripts.Values, run.Settings.Target); // also for a run that is no longer current: its late ends go to its log - if (runs.GetValueOrDefault(id) == run && Find(id) is { } b) - Put(b with { Run = new RunState(run.Start, time.GetUtcNow(), [.. run.Scripts.Values], [.. run.Queue]) }); - foreach (var m in messages) Publish(m); - } - - // Call under the lock. Keeps the last MaxMessages of the attempt, and sends the message to the page if the batch shows this run. - void AddMessage(int id, ActiveRun run, string path, int attempt, SqlMessage m) - { - var (kept, earlier) = run.Messages.GetValueOrDefault((path, attempt), ([], 0)); - var message = new RunMessage(time.GetUtcNow(), m.Kind, m.Text); - kept.Enqueue(message); - if (kept.Count > MaxMessages) { kept.Dequeue(); earlier++; } - run.Messages[(path, attempt)] = (kept, earlier); - if (shown.GetValueOrDefault(id) != run) return; - var change = new Change("message", new ScriptMessage(id, path, attempt, message.Time, m.Kind, m.Text)); - if (held is not null) held.Add(change); else Publish(change); - } - - void Finish(int id, ActiveRun run, string path, DateTimeOffset start, Task t) - { - lock (gate) - { - var end = time.GetUtcNow(); - if (run.Tokens.Remove(path, out var cts)) cts.Dispose(); - var cancelled = run.Cancelled.Remove(path); - run.SetLast(path, t.IsCompletedSuccessfully ? new Attempt(start, end, t.Result.Status, t.Result.Rows, t.Result.Error) - : t.IsCanceled && cancelled ? new Attempt(start, end, ScriptStatus.Cancelled, null, null) - : t.IsCanceled ? new Attempt(start, end, ScriptStatus.Failed, null, $"Timeout after {run.Settings.TimeoutSeconds} s") - : new Attempt(start, end, ScriptStatus.Failed, null, t.Exception!.GetBaseException().Message)); - var a = run.Scripts[path].Attempts[^1]; - if (!t.IsCompletedSuccessfully && a.Error is { } why) AddMessage(id, run, path, run.Scripts[path].Attempts.Count, new(SqlMessageKind.Error, why)); // the executor did not send it - run.Log?.Append($"{Attempt(run, path)}: {a.Status}" + (a.Rows is { } rows ? $", {rows} rows" : "") + (a.Error is { } err ? $": {err}" : "")); - run.Running--; - if (t.IsCompletedSuccessfully && t.Result.LogonFailed) passwords.Remove(id); // the page asks again at the next run - if (run.Scripts[path].Status == ScriptStatus.Failed) run.Failed(); - Pump(id, run); - } - } - - /// "1.sql #2": the script and the number of its last attempt, for run.log. - static string Attempt(ActiveRun run, string path) => $"{path} #{run.Scripts[path].Attempts.Count}"; + /// True if the batch shows the messages of this run. + internal bool Shows(int id, RunEngine run) => shown.GetValueOrDefault(id) == run; /// A log write failed. The page shows the text; the run continues. void Warn(int id, string text) @@ -556,13 +423,13 @@ public Subscription Subscribe() lock (gate) { subscribers.Add(channel); - List messages = [.. shown.SelectMany(r => r.Value.Messages.Select(m => new AttemptMessages(r.Key, m.Key.Path, m.Key.Attempt, m.Value.Earlier, [.. m.Value.Kept])))]; + List messages = [.. shown.Values.SelectMany(r => r.Kept)]; return new Subscription([.. batches], messages, channel.Reader, () => { lock (gate) subscribers.Remove(channel); }); } } // Call under the lock, so each subscriber sees the changes in order. - void Publish(Change change) + internal void Publish(Change change) { foreach (var s in subscribers) s.Writer.TryWrite(change); string[] now = [.. batches.Where(b => b.File is not null).Select(b => b.File!)]; diff --git a/src/SqlRunboard.Core/RunEngine.cs b/src/SqlRunboard.Core/RunEngine.cs new file mode 100644 index 0000000..53ea82d --- /dev/null +++ b/src/SqlRunboard.Core/RunEngine.cs @@ -0,0 +1,188 @@ +namespace SqlRunboard.Core; + +/// +/// The run engine of one run of a batch: its queue and parallel limit, the executors, the attempts, their messages and the log. +/// It has no lock of its own. It shares the lock of its , so each subscriber sees all changes in one order: +/// BatchList calls it under that lock, and the executor callbacks take that lock. +/// +sealed class RunEngine(BatchList list, int id, DateTimeOffset start, string folder, BatchSettings settings, RunLog? log) +{ + readonly OrderedDictionary scripts = new(StringComparer.Ordinal); // the start order + readonly List queue = []; + readonly HashSet cancelled = new(StringComparer.Ordinal); // Running scripts that you cancelled: not a timeout + readonly Dictionary tokens = new(StringComparer.Ordinal); // one per Running script + readonly Dictionary<(string Path, int Attempt), (Queue Kept, int Earlier)> messages = []; + BatchSettings settings = settings; // a rerun sets the current settings; they cannot change while scripts run + int running; + List? held; // while Pump starts executors: their first messages, to publish after the run state + + /// The run as the page sees it, at the server clock now. + public RunState State => new(start, list.Time.GetUtcNow(), [.. scripts.Values], [.. queue]); + + /// The kept messages of each attempt, for a new subscriber. + public IEnumerable Kept => messages.Select(m => new AttemptMessages(id, m.Key.Path, m.Key.Attempt, m.Value.Earlier, [.. m.Value.Kept])); + + /// Run: queues these scripts in this order and starts them. + public void Start(IEnumerable paths) + { + foreach (var path in paths) + { + scripts[path] = new ScriptRun(path, ScriptStatus.Queued, []); + queue.Add(path); + } + Pump(); + } + + /// After a restart: the scripts of the run in summary.csv. Nothing starts. + public void Restore(IEnumerable runs) { foreach (var s in runs) scripts[s.Path] = s; } + + /// All Queued and Running scripts. + public IReadOnlyList Active => [.. queue, .. tokens.Keys]; + + /// All Failed and Cancelled scripts, in the start order. + public IReadOnlyList FailedOrCancelled => [.. scripts.Values.Where(s => s.Status is ScriptStatus.Failed or ScriptStatus.Cancelled).Select(s => s.Path)]; + + /// + /// A Queued script changes to Cancelled with 0 attempts. A Running script changes to Cancelled when its executor stops: + /// cancel the returned tokens outside the lock, because a real executor can block while it tells the server to stop. + /// + public List Cancel(IReadOnlyList paths) + { + List stop = []; + foreach (var path in paths) + if (queue.Remove(path)) scripts[path] = scripts[path] with { Status = ScriptStatus.Cancelled }; + else if (tokens.TryGetValue(path, out var cts) && cancelled.Add(path)) stop.Add(cts); + Pump(); + return stop; + } + + /// Reruns ignore the check boxes. They add attempts to this run, with the current settings but the target of the run. + public void Rerun(IReadOnlyList paths, BatchSettings current) + { + settings = current with { Target = settings.Target }; // reruns use the current parallel limit, stop setting and timeout + foreach (var path in paths) + if (scripts.TryGetValue(path, out var s) && s.Status is ScriptStatus.Succeeded or ScriptStatus.Failed or ScriptStatus.Cancelled) + { + scripts[path] = s with { Status = ScriptStatus.Queued, Error = null }; + queue.Add(path); + } + Pump(); + } + + /// Moves a Queued script in the queue of this run. + public void Move(string path, MoveTo to) + { + if (BatchList.MoveIn(queue, p => p == path, to)) Pump(); + } + + void Add(string path, Attempt a) { var s = scripts[path]; scripts[path] = s with { Status = a.Status, Attempts = [.. s.Attempts, a] }; } + void SetLast(string path, Attempt a) { var s = scripts[path]; scripts[path] = s with { Status = a.Status, Attempts = [.. s.Attempts.SkipLast(1), a] }; } + + /// "Stop the queue at the first failure": no new scripts start. The Running scripts continue. + void Failed() + { + if (!settings.StopOnFirstFailure) return; + foreach (var path in queue) scripts[path] = scripts[path] with { Status = ScriptStatus.Cancelled, Error = "Not started: the queue stopped" }; + queue.Clear(); + } + + // Call under the lock. Starts Queued scripts up to the parallel limit, then publishes the run. + // The executors start before the publish, so a Running script that a subscriber sees has started. + // Their messages wait until after the publish, so the page sees an attempt before its first message. + void Pump() + { + var time = list.Time; + List starts = []; + while (running < settings.ParallelLimit && queue.Count > 0) + { + var path = queue[0]; + queue.RemoveAt(0); + var begin = time.GetUtcNow(); + IReadOnlyList parts; + // Each script reads its file when it starts, so you can fix a Queued script. + // ponytail: the read runs under the lock; move it out if slow network folders block the page. + try { parts = SqlScript.Read(Path.Combine(folder, path)); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + var error = e is FileNotFoundException or DirectoryNotFoundException ? "File not found" : e.Message; + Add(path, new Attempt(begin, begin, ScriptStatus.Failed, null, error)); + log?.Append($"{Attempt(path)}: Failed: {error}"); + var n = scripts[path].Attempts.Count; + starts.Add(() => AddMessage(path, n, new(SqlMessageKind.Error, error))); + Failed(); + continue; + } + running++; + Add(path, new Attempt(begin, null, ScriptStatus.Running, null, null)); + var (attempt, number) = (Attempt(path), scripts[path].Attempts.Count); + log?.Append($"{attempt}: Started"); + var execution = new SqlExecution(path, parts, new SqlConnectionSettings(settings.Target, list.Password(id))); + // The continuation runs on the thread pool, never inline here under the lock. + var cts = tokens[path] = settings.TimeoutSeconds is int s ? new CancellationTokenSource(TimeSpan.FromSeconds(s), time) : new CancellationTokenSource(); + void OnMessage(SqlMessage m) + { + log?.Append($"{attempt}: {(m.Kind == SqlMessageKind.Error ? "Error: " : "")}{m.Text}"); // outside the lock: a file write + lock (list.Gate) AddMessage(path, number, m); + } + starts.Add(() => + { + Task task; + try { task = list.Executor.ExecuteAsync(execution, OnMessage, cts.Token); } + catch (Exception e) { task = Task.FromException(e); } // a throw before the first await fails the attempt too + Task? done = null; + // The continuation takes the lock, so it cannot remove 'done' before this method adds it. + done = task.ContinueWith(t => + { + try { Finish(path, begin, t); } + finally { lock (list.Gate) list.Finishing.Remove(done!); } + }, TaskScheduler.Default); + list.Finishing.Add(done); + }); + } + var outer = held; + held = []; + foreach (var a in starts) a(); + var later = held; + held = outer; + log?.Update(scripts.Values, settings.Target); // also for a run that is no longer current: its late ends go to its log + list.Show(id, this); + foreach (var m in later) list.Publish(m); + } + + // Call under the lock. Keeps the last MaxMessages of the attempt, and sends the message to the page if the batch shows this run. + void AddMessage(string path, int attempt, SqlMessage m) + { + var (kept, earlier) = messages.GetValueOrDefault((path, attempt), ([], 0)); + var message = new RunMessage(list.Time.GetUtcNow(), m.Kind, m.Text); + kept.Enqueue(message); + if (kept.Count > BatchList.MaxMessages) { kept.Dequeue(); earlier++; } + messages[(path, attempt)] = (kept, earlier); + if (!list.Shows(id, this)) return; + var change = new Change("message", new ScriptMessage(id, path, attempt, message.Time, m.Kind, m.Text)); + if (held is not null) held.Add(change); else list.Publish(change); + } + + void Finish(string path, DateTimeOffset begin, Task t) + { + lock (list.Gate) + { + var end = list.Time.GetUtcNow(); + if (tokens.Remove(path, out var cts)) cts.Dispose(); + var stopped = cancelled.Remove(path); + SetLast(path, t.IsCompletedSuccessfully ? new Attempt(begin, end, t.Result.Status, t.Result.Rows, t.Result.Error) + : t.IsCanceled && stopped ? new Attempt(begin, end, ScriptStatus.Cancelled, null, null) + : t.IsCanceled ? new Attempt(begin, end, ScriptStatus.Failed, null, $"Timeout after {settings.TimeoutSeconds} s") + : new Attempt(begin, end, ScriptStatus.Failed, null, t.Exception!.GetBaseException().Message)); + var a = scripts[path].Attempts[^1]; + if (!t.IsCompletedSuccessfully && a.Error is { } why) AddMessage(path, scripts[path].Attempts.Count, new(SqlMessageKind.Error, why)); // the executor did not send it + log?.Append($"{Attempt(path)}: {a.Status}" + (a.Rows is { } rows ? $", {rows} rows" : "") + (a.Error is { } err ? $": {err}" : "")); + running--; + if (t.IsCompletedSuccessfully && t.Result.LogonFailed) list.ForgetPassword(id); // the page asks again at the next run + if (scripts[path].Status == ScriptStatus.Failed) Failed(); + Pump(); + } + } + + /// "1.sql #2": the script and the number of its last attempt, for run.log. + string Attempt(string path) => $"{path} #{scripts[path].Attempts.Count}"; +}