diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e362424 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +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 }} + + # 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 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 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..251eaa4 --- /dev/null +++ b/SqlRunboard.slnx @@ -0,0 +1,12 @@ + + + + + + + + + + + + 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/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/Batch.cs b/src/SqlRunboard.Core/Batch.cs new file mode 100644 index 0000000..7424439 --- /dev/null +++ b/src/SqlRunboard.Core/Batch.cs @@ -0,0 +1,55 @@ +using System.Text.Json.Serialization; + +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(); + + /// 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. +/// 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. +/// 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, 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/BatchFile.cs b/src/SqlRunboard.Core/BatchFile.cs new file mode 100644 index 0000000..751ff6c --- /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 ?? []).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 new file mode 100644 index 0000000..ebcf257 --- /dev/null +++ b/src/SqlRunboard.Core/BatchList.cs @@ -0,0 +1,449 @@ +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. +/// 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 = []; // 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 + int lastId, lastUntitled; + long version; + 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.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)); } + 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 }; + } + Put(b); + } + } + + /// 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; + + public Batch NewBatch() + { + lock (gate) + { + return Put(new Batch(++lastId, $"Untitled {++lastUntitled}", File: null, Dirty: true, new BatchSettings(), Scripts: [])); + } + } + + /// 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)), Run = EndRun(b, settings) }); + } + } + + /// + /// 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; + } + + /// + /// 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 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 || 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) + { + lock (gate) + 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) + { + runs.GetValueOrDefault(id)?.Move(path, to); + 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 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) + { + 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. + internal 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. + /// + 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) + { + 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. + 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) }; + // 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; + return Put(b with { Name = BatchFile.NameOf(file), File = file, Dirty = false, Settings = settings, Scripts = scripts }); + } + } + + /// + /// 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) + { + 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: []))); + } + } + + /// + /// "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)); + } + } + + // 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)), 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] = 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) }; + } + + /// "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); + passwords.Remove(id); + runs.Remove(id); + shown.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. + /// + 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) + { + 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; + } + + /// + /// 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 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 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); + } + } + + /// + /// 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.Active); + + Batch? Cancel(int id, Func> paths) + { + List stop = []; + Batch? batch; + lock (gate) + { + 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. + foreach (var cts in stop) + try { cts.Cancel(); } catch (ObjectDisposedException) { } // the script ended first + 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. + /// + 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.FailedOrCancelled); + + Batch? Rerun(int id, Func> paths) + { + lock (gate) + { + if (runs.GetValueOrDefault(id) is { } run && Find(id) is { } batch) run.Rerun(paths(run), batch.Settings); + return Find(id); + } + } + + // 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) + { + if (runs.GetValueOrDefault(id) == run && Find(id) is { } b) Put(b with { Run = run.State }); + } + /// 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) + { + 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. + 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, 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.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. + public Subscription Subscribe() + { + var channel = Channel.CreateUnbounded(); + lock (gate) + { + subscribers.Add(channel); + 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. + 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!)]; + if (now.SequenceEqual(files)) return; + files = now; + FilesChanged?.Invoke(now); + } + + 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/FakeSqlExecutor.cs b/src/SqlRunboard.Core/FakeSqlExecutor.cs new file mode 100644 index 0000000..2bdbd17 --- /dev/null +++ b/src/SqlRunboard.Core/FakeSqlExecutor.cs @@ -0,0 +1,43 @@ +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; } = []; + public bool LogonFailed { 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 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) + { + 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, script.LogonFailed); + } + + public Task LogonAsync(SqlConnectionSettings connection, CancellationToken ct) + { + Logons.Enqueue(connection); + return Task.FromResult(Logon(connection)); + } +} 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/src/SqlRunboard.Core/Run.cs b/src/SqlRunboard.Core/Run.cs new file mode 100644 index 0000000..f574e0a --- /dev/null +++ b/src/SqlRunboard.Core/Run.cs @@ -0,0 +1,26 @@ +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. 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. +/// 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, bool Ended = false) +{ + 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/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}"; +} diff --git a/src/SqlRunboard.Core/RunLog.cs b/src/SqlRunboard.Core/RunLog.cs new file mode 100644 index 0000000..abd85ac --- /dev/null +++ b/src/SqlRunboard.Core/RunLog.cs @@ -0,0 +1,178 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +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); + } + + /// 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(d => Path.GetFileName(d), 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) + { + 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) + { + 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 + { + 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/src/SqlRunboard.Core/SqlExecutor.cs b/src/SqlRunboard.Core/SqlExecutor.cs new file mode 100644 index 0000000..97242ef --- /dev/null +++ b/src/SqlRunboard.Core/SqlExecutor.cs @@ -0,0 +1,31 @@ +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 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. LogonFailed: the server rejected the logon (wrong password). +public sealed record SqlResult(ScriptStatus Status, long? Rows, string? Error, bool LogonFailed = false); + +/// 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 new file mode 100644 index 0000000..7ad41f4 --- /dev/null +++ b/src/SqlRunboard.Core/SqlRunboard.Core.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/src/SqlRunboard.Core/SqlScript.cs b/src/SqlRunboard.Core/SqlScript.cs new file mode 100644 index 0000000..6192c45 --- /dev/null +++ b/src/SqlRunboard.Core/SqlScript.cs @@ -0,0 +1,71 @@ +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 +{ + /// 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. + /// + 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/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/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs new file mode 100644 index 0000000..c3c6e94 --- /dev/null +++ b/src/SqlRunboard.Gui/GuiHost.cs @@ -0,0 +1,320 @@ +using System.Diagnostics; +using System.Net; +using System.Net.ServerSentEvents; +using System.Runtime.CompilerServices; +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; +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. +/// 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); +/// 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); +public sealed record LogonRequest(string? Password); +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 +{ + 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, string lockPath) { this.app = app; BaseUrl = baseUrl; Token = token; this.lockPath = lockPath; } + + 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.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); + builder.Services.AddSingleton(options.Time); + builder.Services.AddSingleton(new BatchList(options.Executor, options.Time, options.AppDataFolder)); + builder.Services.AddSingleton(); + builder.Services.ConfigureHttpJsonOptions(j => j.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); + + var app = builder.Build(); + 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"); + 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, 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(); + 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.IsValid ? Results.BadRequest() + : Found(list.UpdateSettings(id, settings))); + app.MapPost("/api/batches/{id:int}/include", (int id, IncludeRequest r, BatchList list) => + Found(list.SetInclude(id, r.Path, r.Include))); + app.MapPost("/api/batches/{id:int}/logon", async (int id, LogonRequest r, BatchList list, CancellationToken ct) => + Found(await list.LogonAsync(id, r.Password, ct))); + app.MapPost("/api/batches/{id:int}/run", (int id, BatchList list) => + Found(list.Run(id))); + app.MapPost("/api/batches/{id:int}/cancel", (int id, ScriptRequest r, BatchList list) => + Found(list.Cancel(id, r.Path))); + app.MapPost("/api/batches/{id:int}/cancel-all", (int id, BatchList list) => + Found(list.CancelAll(id))); + app.MapPost("/api/batches/{id:int}/move", (int id, MoveRequest r, BatchList list) => + Found(list.Move(id, r.Path, r.To))); + app.MapPost("/api/batches/{id:int}/sort", (int id, BatchList list) => + 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) => + Found(list.Rerun(id, r.Path))); + app.MapPost("/api/batches/{id:int}/rerun-failed", (int id, BatchList list) => + 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))); + 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 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. + (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 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 }); + }); + + 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(() => + { + StopRunsAsync(app.Services.GetRequiredService()).GetAwaiter().GetResult(); + File.Delete(lockPath); + }); + + await app.StartAsync(); + var host = new GuiHost(app, new Uri(app.Urls.First()), token, lockPath); + host.WriteLock(); + 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. + /// + 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 => + { + 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); } + }; + } + + /// 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 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); } + 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). + 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() + { + 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); + } + + /// 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"); + 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(); + + /// + /// 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; + if (origin.Count > 0 && origin != $"{ctx.Request.Scheme}://{ctx.Request.Host}") + { + 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); + } + + /// 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(); + } +} + +/// +/// 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(); + // 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); + } + } + + 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/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.Gui/wwwroot/index.html b/src/SqlRunboard.Gui/wwwroot/index.html new file mode 100644 index 0000000..8e2663d --- /dev/null +++ b/src/SqlRunboard.Gui/wwwroot/index.html @@ -0,0 +1,918 @@ + + + + + + +SQLRunboard + + + +
+ +
+ +
+
+
+
+
+ + +
+
+
+ +
+
+ + + diff --git a/src/sqlrunboard-desktop/Program.cs b/src/sqlrunboard-desktop/Program.cs new file mode 100644 index 0000000..b98d965 --- /dev/null +++ b/src/sqlrunboard-desktop/Program.cs @@ -0,0 +1,93 @@ +using Avalonia; +using Avalonia.Controls; +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 ]"; + +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) +{ + host = GuiHost.StartAsync(new GuiHostOptions(appData, new SqlServerExecutor(), TimeProvider.System)).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. +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 = 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(CloseNow), TaskScheduler.Default); +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 + + + + + + + + diff --git a/src/sqlrunboard/Program.cs b/src/sqlrunboard/Program.cs new file mode 100644 index 0000000..3352808 --- /dev/null +++ b/src/sqlrunboard/Program.cs @@ -0,0 +1,36 @@ +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; + } +} + +// 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; +} + +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; + +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/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/BatchFileTests.cs b/tests/SqlRunboard.Core.Tests/BatchFileTests.cs new file mode 100644 index 0000000..c7747c7 --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/BatchFileTests.cs @@ -0,0 +1,208 @@ +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(new FakeSqlExecutor(TimeProvider.System), TimeProvider.System); // no runs here + + 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); + } + + [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 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() + { + 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); +} 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); + } +} diff --git a/tests/SqlRunboard.Core.Tests/FakeSqlExecutorTests.cs b/tests/SqlRunboard.Core.Tests/FakeSqlExecutorTests.cs new file mode 100644 index 0000000..207bb2e --- /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", [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)); + 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([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); + } + + [Fact] + public async Task Unknown_script_succeeds_at_once() + { + var executor = new FakeSqlExecutor(new FakeTime()); + + 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); + } + + [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", [new ScriptPart("SELECT 1", 1, 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..68ec1fc --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/FakeTime.cs @@ -0,0 +1,70 @@ +/// 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; } + /// 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; + + 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; } + } +} 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); +} diff --git a/tests/SqlRunboard.Core.Tests/LogonTests.cs b/tests/SqlRunboard.Core.Tests/LogonTests.cs new file mode 100644 index 0000000..6e3d77a --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/LogonTests.cs @@ -0,0 +1,113 @@ +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 : IAsyncDisposable +{ + 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)); + + /// 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 new file mode 100644 index 0000000..fac5fc5 --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/MessageTests.cs @@ -0,0 +1,108 @@ +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 : IAsyncDisposable +{ + 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)); + } + + [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)); + } + + /// 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 new file mode 100644 index 0000000..0b60cc0 --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/RestoreTests.cs @@ -0,0 +1,212 @@ +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 : IAsyncDisposable +{ + readonly string root = Directory.CreateTempSubdirectory("sqlrunboard-restore-").FullName; + readonly FakeTime time = new(); + readonly FakeSqlExecutor executor; + 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); + } + + /// 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); + } + + /// 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); + lists.Add(again); + 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_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_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() + { + 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 + } +} diff --git a/tests/SqlRunboard.Core.Tests/RunLogTests.cs b/tests/SqlRunboard.Core.Tests/RunLogTests.cs new file mode 100644 index 0000000..754a9d4 --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/RunLogTests.cs @@ -0,0 +1,260 @@ +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 : IAsyncDisposable +{ + 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(); + } + + /// 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); + } + + 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_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() + { + 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, 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"))); + 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)); + } + + [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() + { + 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"))); + } +} diff --git a/tests/SqlRunboard.Core.Tests/RunTests.cs b/tests/SqlRunboard.Core.Tests/RunTests.cs new file mode 100644 index 0000000..85de861 --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/RunTests.cs @@ -0,0 +1,410 @@ +using SqlRunboard.Core; + +/// The run engine, seen through BatchList: Run, then the published batch changes. Fake executor + fake clock. +public sealed class RunTests : IAsyncDisposable +{ + 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)); + 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)); + } + + await Until(b.Id, r => !r.Active); + 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() + { + 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() + { + 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([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"); + } + + [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([("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)); + } + + [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)); + } + + [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"); + } + + [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 = Path.Combine(root, "other") }, + "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)); + } + + [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() + { + 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() + { + 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); + } + + /// 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 new file mode 100644 index 0000000..82a30f3 --- /dev/null +++ b/tests/SqlRunboard.Core.Tests/ScriptOrderTests.cs @@ -0,0 +1,124 @@ +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 : IAsyncDisposable +{ + 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); + } + + [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(["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)!; + + 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); + } + + [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)); + } + + /// 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/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.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")]); + } +} 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/AppSettingsTests.cs b/tests/SqlRunboard.Gui.Tests/AppSettingsTests.cs new file mode 100644 index 0000000..68e8298 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/AppSettingsTests.cs @@ -0,0 +1,79 @@ +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 +{ + 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 + } + + [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\"")] + 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); + } +} diff --git a/tests/SqlRunboard.Gui.Tests/BatchFileTests.cs b/tests/SqlRunboard.Gui.Tests/BatchFileTests.cs new file mode 100644 index 0000000..52e6611 --- /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 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"); + 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 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"); + 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 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(); + + 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/BoardTests.cs b/tests/SqlRunboard.Gui.Tests/BoardTests.cs new file mode 100644 index 0000000..8996b80 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/BoardTests.cs @@ -0,0 +1,174 @@ +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(); + } + + [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"); + } + + [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(); + Directory.Delete(scripts, recursive: true); + } +} 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); + } +} 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/EventsTests.cs b/tests/SqlRunboard.Gui.Tests/EventsTests.cs new file mode 100644 index 0000000..03ec7bd --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/EventsTests.cs @@ -0,0 +1,54 @@ +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()); + } + + [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()); + } +} diff --git a/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs b/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs new file mode 100644 index 0000000..4710b22 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/ExecutableTests.cs @@ -0,0 +1,113 @@ +using System.Diagnostics; +using System.Net; + +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() + { + 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() + { + var appData = Path.Combine(Path.GetTempPath(), "sqlrunboard-exe-" + Guid.NewGuid().ToString("N")); + 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) ?? ""; + 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 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; + using var exe = Process.Start(Exe("--app-data", appData, "--no-browser"))!; + 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() + { + await using var app = await TestApp.StartAsync(); + 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); + await exe.WaitForExitAsync(Ct).WaitAsync(TimeSpan.FromSeconds(30), 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() + { + var psi = Exe("--bogus"); + psi.RedirectStandardError = true; + using var exe = Process.Start(psi)!; + 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); + } + finally + { + if (!exe.HasExited) exe.Kill(entireProcessTree: true); + } + } +} diff --git a/tests/SqlRunboard.Gui.Tests/HostTests.cs b/tests/SqlRunboard.Gui.Tests/HostTests.cs new file mode 100644 index 0000000..25ad949 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/HostTests.cs @@ -0,0 +1,77 @@ +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); + } + + [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/InstanceLockTests.cs b/tests/SqlRunboard.Gui.Tests/InstanceLockTests.cs new file mode 100644 index 0000000..449cb18 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/InstanceLockTests.cs @@ -0,0 +1,50 @@ +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)); + } + + [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); + } +} diff --git a/tests/SqlRunboard.Gui.Tests/LogonTests.cs b/tests/SqlRunboard.Gui.Tests/LogonTests.cs new file mode 100644 index 0000000..66c10d4 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/LogonTests.cs @@ -0,0 +1,156 @@ +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 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 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() + { + 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"]); + } +} 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(); + } +} diff --git a/tests/SqlRunboard.Gui.Tests/QuitTests.cs b/tests/SqlRunboard.Gui.Tests/QuitTests.cs new file mode 100644 index 0000000..b9aac22 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/QuitTests.cs @@ -0,0 +1,129 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.Playwright; +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); + } +} + +/// 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)); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + Directory.Delete(root, recursive: true); + } +} diff --git a/tests/SqlRunboard.Gui.Tests/RequestSecurityTests.cs b/tests/SqlRunboard.Gui.Tests/RequestSecurityTests.cs new file mode 100644 index 0000000..b6647b2 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/RequestSecurityTests.cs @@ -0,0 +1,60 @@ +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); + } + + [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); + } + + [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); + } +} diff --git a/tests/SqlRunboard.Gui.Tests/RestartTests.cs b/tests/SqlRunboard.Gui.Tests/RestartTests.cs new file mode 100644 index 0000000..89819a0 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/RestartTests.cs @@ -0,0 +1,62 @@ +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 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(); + 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); + } +} diff --git a/tests/SqlRunboard.Gui.Tests/RunLogTests.cs b/tests/SqlRunboard.Gui.Tests/RunLogTests.cs new file mode 100644 index 0000000..5ee6914 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/RunLogTests.cs @@ -0,0 +1,31 @@ +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"); + 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 + } +} diff --git a/tests/SqlRunboard.Gui.Tests/RunViewTests.cs b/tests/SqlRunboard.Gui.Tests/RunViewTests.cs new file mode 100644 index 0000000..2069252 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/RunViewTests.cs @@ -0,0 +1,132 @@ +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}')"); + // 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() + { + 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 OpenRun(b.Name); + 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 + 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"); + 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 OpenRun(b.Name); + 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 OpenRun(b.Name); + 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 OpenRun(b.Name); + 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 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"); + } + + [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(); + Directory.Delete(scripts, recursive: true); + } +} 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); + } +} 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); + } +} diff --git a/tests/SqlRunboard.Gui.Tests/ShellBridgeTests.cs b/tests/SqlRunboard.Gui.Tests/ShellBridgeTests.cs new file mode 100644 index 0000000..efa07d0 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/ShellBridgeTests.cs @@ -0,0 +1,127 @@ +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(""); + } + + [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(); + Directory.Delete(root, recursive: true); + } +} 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..0d9770a --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/SqlRunboard.Gui.Tests.csproj @@ -0,0 +1,15 @@ + + + Exe + + + + + + + + + + + + diff --git a/tests/SqlRunboard.Gui.Tests/TestApp.cs b/tests/SqlRunboard.Gui.Tests/TestApp.cs new file mode 100644 index 0000000..72c36e6 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/TestApp.cs @@ -0,0 +1,39 @@ +using System.Collections.Concurrent; +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; } + /// 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 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. + 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); + } +} diff --git a/tests/SqlRunboard.Gui.Tests/TimelineTests.cs b/tests/SqlRunboard.Gui.Tests/TimelineTests.cs new file mode 100644 index 0000000..3751b65 --- /dev/null +++ b/tests/SqlRunboard.Gui.Tests/TimelineTests.cs @@ -0,0 +1,272 @@ +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"]); + } + + [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")); + } + + [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"); + } + + [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"); + } + + 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", "", "", "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 + var head = Panel.Locator(".tlg > .striphead"); + await Expect(head.Locator("svg.strip")).ToHaveCountAsync(1); + await Expect(head.Locator(".axis span").First).ToHaveTextAsync("0:00"); + // 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); + } + + 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(); + Directory.Delete(scripts, recursive: true); + } +} 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); + } +}