From 10df40d4a8f7b5a11777cfd70accf1401868bbd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:32:16 +0000 Subject: [PATCH 1/2] Add a headless app gallery rendered into docs/gallery The gallery is a set of MSTest scenarios in a new tests/ProjectDirector.UITests project. Each one starts the real application through ktsu.ImGui.App.Testing, stages a view and saves a picture of it. A last test writes docs/gallery/README.md, which captions every picture. The six pictures show: - selecting a repository - comparing two repositories - comparing a file side by side - propagating a file - the Set Dev Directory dialog - the Add GitHub Owner dialog The pictures are byte-identical from run to run. The app's settings file and secret store are replaced with in-memory ones, and the three repositories it compares are seeded offline with pinned git authors and dates under a fixed path. Fetching on a timer is turned off, and every picture is cropped above the log panel, which prints wall-clock times. Reaching the app from a test needed a small refactor: - Main now starts the app through an internal BuildConfig(), and an internal constructor takes the settings to use. - The views a test clicks are marked with ImGuiProbes.MarkItem. - The file browser lists its entries in a case-insensitive name order rather than whatever order the file system returns them in. app-gallery.yml regenerates the pictures and commits them after CI passes on main, called from ci.yml the same way ImGuiApp's widget gallery is. No PNGs are committed here; the first run on main writes them. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WWRBkw7bXdWGzuLpqnCvrB --- .github/workflows/app-gallery.yml | 161 +++++++++++ .github/workflows/ci.yml | 9 + CLAUDE.md | 2 + Directory.Packages.props | 3 + ProjectDirector.sln | 6 + ProjectDirector/AssemblyInfo.cs | 1 + ProjectDirector/ProjectDirector.cs | 81 ++++-- ProjectDirector/ProjectDirector.csproj | 1 + README.md | 2 + tests/ProjectDirector.UITests/AssemblyInfo.cs | 6 + .../Gallery/AppGallery.cs | 127 +++++++++ .../Gallery/GalleryApp.cs | 115 ++++++++ .../Gallery/GalleryCatalog.cs | 91 +++++++ .../Gallery/GalleryEntry.cs | 50 ++++ .../Gallery/GalleryFonts.cs | 65 +++++ .../Gallery/GalleryIndex.cs | 32 +++ .../Gallery/GalleryRepositories.cs | 251 ++++++++++++++++++ .../ProjectDirector.UITests.csproj | 24 ++ 18 files changed, 1008 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/app-gallery.yml create mode 100644 tests/ProjectDirector.UITests/AssemblyInfo.cs create mode 100644 tests/ProjectDirector.UITests/Gallery/AppGallery.cs create mode 100644 tests/ProjectDirector.UITests/Gallery/GalleryApp.cs create mode 100644 tests/ProjectDirector.UITests/Gallery/GalleryCatalog.cs create mode 100644 tests/ProjectDirector.UITests/Gallery/GalleryEntry.cs create mode 100644 tests/ProjectDirector.UITests/Gallery/GalleryFonts.cs create mode 100644 tests/ProjectDirector.UITests/Gallery/GalleryIndex.cs create mode 100644 tests/ProjectDirector.UITests/Gallery/GalleryRepositories.cs create mode 100644 tests/ProjectDirector.UITests/ProjectDirector.UITests.csproj diff --git a/.github/workflows/app-gallery.yml b/.github/workflows/app-gallery.yml new file mode 100644 index 0000000..d4c2025 --- /dev/null +++ b/.github/workflows/app-gallery.yml @@ -0,0 +1,161 @@ +name: App Gallery + +# Regenerates docs/gallery after CI finishes on main, and commits the images back to main when they +# differ. Nothing is committed when the output is byte-identical, which it is run to run: the +# gallery is a set of headless UI tests (tests/ProjectDirector.UITests/Gallery) rendering on the +# CPU rasterizer over three git repositories seeded offline with pinned names, dates and contents, +# with the settings file and the secret store replaced by in-memory ones and the log panel, whose +# lines carry wall-clock times, cropped out. The only thing that moves a picture is a change to the +# code that draws it. +# +# ci.yml calls this as a job that needs its pipeline, only for a push to main; it can also be run by +# hand on main. It is deliberately not a workflow_run workflow. workflow_run runs with the +# repository's own token and secrets whatever started the run it follows, including a pull request +# from a fork, so building checked-out code there is the pattern githubactions:S7631 exists to catch. +# Here the only things that can start a run are a push to main, which needs write access, and a +# dispatch by someone with write access, so nothing a fork wrote is ever checked out or built with +# this job's permissions. +# +# Why after CI rather than beside it: CI's release job commits VERSION.md and friends back to main +# with a plain `git push` from the commit it was started on. A gallery commit landing on main first +# makes that push non-fast-forward, and the release fails. Needing the pipeline means main already +# carries the release commit, and CI's own cancel-in-progress group means no other main run is part +# way to a release of its own when this one pushes. A failed pipeline skips this job. +# +# There is no paths filter, because the triggering commit is not the only one this run covers: CI +# cancels a superseded run, so a change to a panel can reach main under the CI run of the push after +# it. Regenerating after every successful main run costs a couple of minutes and commits nothing +# when nothing changed. +# +# Why the commit back cannot loop or cut a release: +# - It is pushed with the workflow's own GITHUB_TOKEN, and GitHub starts no workflow run for a +# push made with that token. Neither CI nor, through it, this workflow sees it. +# - It carries `[bot][skip ci]`, the prefix KtsuBuild's own metadata commits use. Should the +# token ever change to one that does trigger runs, GitHub still skips push workflows for it, +# and KtsuBuild leaves it out of the version calculation and the changelog either way. +# +# Why it can push to a protected main: the main ruleset lists github-actions[bot] (the identity +# behind GITHUB_TOKEN) as an always-bypass actor, which is also how KtsuBuild pushes its metadata +# commits. No extra token or ruleset change is needed. +# +# This workflow never runs for a pull request, so it does not belong in dependabot-merge.yml's list +# of pull-request workflows. + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +env: + DOTNET_VERSION: "10.0" + +jobs: + regenerate: + name: Regenerate app gallery + runs-on: ubuntu-latest + timeout-minutes: 20 + # Only on main, and never on a fork, where there is nothing of ours to commit to. ci.yml only + # calls this for a push to main, so this is what keeps a manual run off other branches. + if: github.repository == 'ktsu-dev/ProjectDirector' && github.ref == 'refs/heads/main' + # A newer run supersedes an older one: both regenerate from the current main, so an older run + # that is still going has nothing left worth committing. Set on the job rather than the + # workflow, because a called workflow's github.workflow is the caller's name. + concurrency: + group: app-gallery + cancel-in-progress: true + permissions: + contents: write # To push the regenerated images to main + + steps: + # LFS content is not fetched: nothing the gallery builds from is in LFS, and every image it + # writes replaces the pointer that was there. The LFS filters are installed below instead, + # which is what makes git compare and commit the images as LFS objects. + - name: Checkout Repository + uses: actions/checkout@v7 + with: + # The current main, which by now carries CI's release commit, not the commit CI ran on. + # Never the triggering event's ref or SHA. + ref: main + fetch-depth: 1 + lfs: false + persist-credentials: true + + - name: Setup .NET SDK ${{ env.DOTNET_VERSION }} + uses: actions/setup-dotnet@v6 + with: + dotnet-version: ${{ env.DOTNET_VERSION }}.x + cache: true + cache-dependency-path: | + **/*.csproj + **/Directory.Packages.props + **/global.json + + - name: Install Git LFS filters + run: git lfs install --local + + - name: Build the UI tests + run: dotnet build tests/ProjectDirector.UITests/ProjectDirector.UITests.csproj -c Release + + # The old images and index are removed first so that a picture whose entry was renamed or + # removed goes with it, rather than lingering beside its replacement. A stage that fails is a + # failed test, which fails the job before anything is committed. + - name: Regenerate the gallery + env: + PROJECTDIRECTOR_GALLERY_OUT: docs/gallery + run: | + rm -f docs/gallery/*.png docs/gallery/README.md + tests/ProjectDirector.UITests/bin/Release/net10.0/ktsu.ProjectDirector.UITests \ + --filter "FullyQualifiedName~ktsu.ProjectDirector.UITests.Gallery" + + # Only docs/gallery is staged. The build also rewrites some tracked files (ktsu.Sdk syncs + # .gitignore and friends from its own copy), and none of that belongs in this commit. + - name: Commit and push the images + env: + # A rebase below checks files out, and nothing here reads an image's content, so there + # is no reason to download LFS objects for it. Pointers stay pointers. + GIT_LFS_SKIP_SMUDGE: "1" + run: | + source_sha=$(git rev-parse HEAD) + git add --all docs/gallery + + if git diff --cached --quiet; then + echo "The app gallery is up to date." + exit 0 + fi + + git diff --cached --stat + + # A PNG committed as a blob rather than as an LFS pointer would bloat the history for + # good, so refuse to push one. + for path in $(git diff --cached --name-only --diff-filter=AM -- '*.png'); do + header=$(git cat-file blob ":$path" | head -c 42 | tr -d '\0' || true) + if [[ "$header" != "version https://git-lfs.github.com/spec/v1" ]]; then + echo "::error file=$path::Staged as a raw blob rather than an LFS pointer." + exit 1 + fi + done + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit --quiet --message "[bot][skip ci] Regenerate the app gallery" \ + --message "Rendered by .github/workflows/app-gallery.yml from $source_sha." + + # Drop what the build rewrote and was left unstaged, which a rebase refuses to run over. + git reset --hard --quiet HEAD + + # Main may still have moved on since checkout, a manual dispatch racing a release being + # the likely way. The commits touch different files, so a rebase is clean. + for attempt in 1 2 3 4 5; do + if git push origin HEAD:main; then + exit 0 + fi + echo "Push rejected (attempt $attempt); rebasing onto the current main." + sleep $((attempt * 5)) + git fetch --depth=50 origin main + git rebase FETCH_HEAD + done + + echo "::error::Could not push the regenerated gallery to main." + exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f3bd1f..87f294a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,3 +41,12 @@ jobs: with: # `inputs` is empty on push, pull_request and schedule; only a dispatch sets it. version-bump: ${{ inputs.version-bump || 'auto' }} + + # Regenerates docs/gallery once the pipeline has passed on a push to main. It has to run after + # the pipeline, not beside it: see the header of app-gallery.yml. + app-gallery: + needs: ci + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: ./.github/workflows/app-gallery.yml + permissions: + contents: write diff --git a/CLAUDE.md b/CLAUDE.md index c9bad2c..f950d76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,8 @@ dotnet publish --configuration Release --output ./staging Tests live in `ProjectDirector.Test` (MSTest, via `MSTest.Sdk` + `ktsu.Sdk`). The app exposes its internals to the test project through `InternalsVisibleTo` in `ProjectDirector/AssemblyInfo.cs`. `GitCliTests` drives `GitCli` against throwaway repositories under the temp directory; the ImGui layer is not unit-tested. +`tests/ProjectDirector.UITests` drives the real application headlessly through `ktsu.ImGui.App.Testing` to render the app gallery (`Gallery/`), and exists for that purpose. It reaches the app through `ProjectDirector.BuildConfig()` and the internal options constructor. It replaces the settings file and the secret store with in-memory ones, and points the app at three repositories it seeds offline under `/tmp/projectdirector-gallery`. That path is fixed because it appears in the pictures. Every picture is cropped above the log panel, because the log prints wall-clock timestamps. `PROJECTDIRECTOR_GALLERY_OUT` names the output directory; `.github/workflows/app-gallery.yml` regenerates `docs/gallery/` after CI passes on `main`. A new view or dialog worth showing gets an entry in `Gallery/GalleryCatalog.cs`. + That last point is why the repository actions are shaped the way they are: the part of each with a rule in it is pulled out into a plain method so it can be driven without a live ImGui context or a display. `ProjectDirector.DecidePull` decides whether pulling interrupts the user first (`PullDecisionTests`), and `GitCli.ListPendingChanges` plus `ProjectDirector.DescribePendingChanges` decide what a commit will sweep up and how that is shown (`CommitTests`). `DiffTake` builds the file a take arrow in the diff view produces and writes it back with the destination's own line ending and BOM (`DiffTakeTests`). Anything genuinely worth testing that is still tangled up with drawing is usually worth extracting the same way. ```powershell diff --git a/Directory.Packages.props b/Directory.Packages.props index c1a96ac..b102848 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,7 +10,9 @@ + + @@ -26,5 +28,6 @@ + \ No newline at end of file diff --git a/ProjectDirector.sln b/ProjectDirector.sln index 308d12d..3e1b417 100644 --- a/ProjectDirector.sln +++ b/ProjectDirector.sln @@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectDirector", "ProjectD EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectDirector.Test", "ProjectDirector.Test\ProjectDirector.Test.csproj", "{7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProjectDirector.UITests", "tests\ProjectDirector.UITests\ProjectDirector.UITests.csproj", "{3F6B2D8A-9C41-4E7B-A5D2-6E8F1B0C7D93}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ Global {7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Debug|Any CPU.Build.0 = Debug|Any CPU {7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Release|Any CPU.ActiveCfg = Release|Any CPU {7C1B4E90-58F1-4C2E-9B3D-2A6F0D5E8C41}.Release|Any CPU.Build.0 = Release|Any CPU + {3F6B2D8A-9C41-4E7B-A5D2-6E8F1B0C7D93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3F6B2D8A-9C41-4E7B-A5D2-6E8F1B0C7D93}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3F6B2D8A-9C41-4E7B-A5D2-6E8F1B0C7D93}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3F6B2D8A-9C41-4E7B-A5D2-6E8F1B0C7D93}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ProjectDirector/AssemblyInfo.cs b/ProjectDirector/AssemblyInfo.cs index 4d64088..fdf24c2 100644 --- a/ProjectDirector/AssemblyInfo.cs +++ b/ProjectDirector/AssemblyInfo.cs @@ -1,3 +1,4 @@ // Copyright (c) 2023-2026 ktsu-dev contributors [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ktsu.ProjectDirector.Test")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ktsu.ProjectDirector.UITests")] diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs index 1ea98b6..831b6ad 100644 --- a/ProjectDirector/ProjectDirector.cs +++ b/ProjectDirector/ProjectDirector.cs @@ -15,6 +15,7 @@ namespace ktsu.ProjectDirector; using ktsu.Extensions; using ktsu.ImGui.App; using ktsu.ImGui.Popups; +using ktsu.ImGui.Probes; using ktsu.ImGui.Widgets; using ktsu.ImGui.Styler; using Octokit; @@ -63,23 +64,39 @@ internal sealed class ProjectDirector // private ChatClient ChatClient { get; init; } - private static void Main(string[] _) + private static void Main(string[] _) => ImGuiApp.Start(new ProjectDirector().BuildConfig()); + + /// + /// Builds the configuration starts the application with. + /// + /// The configuration, wired to this instance. + /// + /// Separate from so a headless test can start exactly the configuration the + /// application runs, rather than a copy of it. + /// + internal ImGuiAppConfig BuildConfig() => new() { - ProjectDirector projectDirector = new(); - ImGuiApp.Start(new() - { - Title = "Project Director", - OnAppMenu = projectDirector.ShowMenu, - OnMoveOrResize = projectDirector.WindowResized, - OnRender = projectDirector.Tick, - }); - } + Title = "Project Director", + OnAppMenu = ShowMenu, + OnMoveOrResize = WindowResized, + OnRender = Tick, + }; private const int LogLinesMax = 100; public ProjectDirector() + : this(ProjectDirectorOptions.LoadOrCreate()) + { + } + + /// + /// Initializes a new instance of the class over options the caller + /// supplies, instead of the ones saved on this machine. + /// + /// The options to start from. + internal ProjectDirector(ProjectDirectorOptions options) { - Options = ProjectDirectorOptions.LoadOrCreate(); + Options = options; _ = MakeLoadedOptionsSafe(Options, QueueLog); @@ -829,6 +846,7 @@ private void ShowBottomPanel(float dt) } ImGui.EndChild(); + ImGuiProbes.MarkItem("Log"); } private void ShowLeftPanel(float dt) @@ -886,9 +904,13 @@ private void ShowCollapsiblePanel(string name, Action contentDelegate) private void ShowMenu() { - if (ImGui.BeginMenu("File")) + bool fileMenuOpen = ImGui.BeginMenu("File"); + ImGuiProbes.MarkItem("menu/File"); + if (fileMenuOpen) { - if (ImGui.MenuItem("Set Dev Directory")) + bool setDevDirectory = ImGui.MenuItem("Set Dev Directory"); + ImGuiProbes.MarkItem("menu/Set Dev Directory"); + if (setDevDirectory) { PopupSetDevDirectory.Open("Set Dev Directory?", "Set Dev Directory?", Options.DevDirectory, result => { @@ -915,7 +937,9 @@ private void ShowMenu() ImGui.Separator(); - if (ImGui.MenuItem("Add New GitHub Owner")) + bool addOwner = ImGui.MenuItem("Add New GitHub Owner"); + ImGuiProbes.MarkItem("menu/Add New GitHub Owner"); + if (addOwner) { PopupAddNewGitHubOwner.Open("New Owner Name?", "New Owner Name?", "ktsu-io", result => { @@ -988,7 +1012,9 @@ private void ShowRepos(GitHubOwnerName owner) ImGuiWidgets.ColorIndicator(Palette.Basic.Green, isCloned); ImGui.SameLine(); bool isSelected = Options.BaseRepo == repoName; - if (ImGui.Selectable(gitHubRepo.RepoName, ref isSelected)) + bool clicked = ImGui.Selectable(gitHubRepo.RepoName, ref isSelected); + ImGuiProbes.MarkItem("repo", gitHubRepo.RepoName); + if (clicked) { SwitchPage(repoName); } @@ -1710,6 +1736,7 @@ private void ShowSimilarRepos(GitRepository repo) if (ImGui.TableNextColumn()) { _ = ImGui.Selectable(otherRepoName, selected: false, ImGuiSelectableFlags.SpanAllColumns); + ImGuiProbes.MarkItem("similar", otherRepoName); if (ImGui.IsItemHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) { SwitchPage(Options.BaseRepo, otherRepoName); @@ -1765,6 +1792,7 @@ private void ShowComparedRepo(GitRepository repo) if (ImGui.TableNextColumn()) { _ = ImGui.Selectable(filePath, selected: false, ImGuiSelectableFlags.SpanAllColumns); + ImGuiProbes.MarkItem("changed", filePath); if (ImGui.IsItemHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) { SwitchPage(Options.BaseRepo, Options.CompareRepo, filePath); @@ -2294,11 +2322,14 @@ private void ShowRepoBrowser() if (ImGui.TableNextColumn()) { _ = ImGui.Selectable(path); + ImGuiProbes.MarkItem("browse", path); if (ImGui.BeginPopupContextItem(path, ImGuiPopupFlags.MouseButtonRight)) { ImGui.Selectable(path); ImGui.Separator(); - if (ImGui.Selectable($"Propagate")) + bool propagate = ImGui.Selectable($"Propagate"); + ImGuiProbes.MarkItem("browse-menu/Propagate"); + if (propagate) { shouldOpenPopup |= true; Options.PropagatePath = path; @@ -2378,6 +2409,18 @@ private void ShowRepoBrowser() _ = PopupPropagateFile.ShowIfOpen(); } + /// + /// Lists a directory for the repository browsers, in name order. + /// + /// The directory to list. + /// The full paths of its files and directories, ordered by name ignoring case. + /// + /// The file system's own order is whatever that file system keeps: alphabetical on NTFS, by hash + /// on ext4. Sorting makes the browsers read the same on every machine. + /// + internal static IEnumerable ListBrowserEntries(string directory) => + Directory.EnumerateFileSystemEntries(directory).Order(StringComparer.OrdinalIgnoreCase); + private void SwitchCompareBrowserPath(FullyQualifiedGitHubRepoName baseRepo, FullyQualifiedGitHubRepoName compareRepo, RelativeDirectoryPath newPath) { Options.BrowsePath = newPath; @@ -2391,7 +2434,7 @@ private void SwitchCompareBrowserPath(FullyQualifiedGitHubRepoName baseRepo, Ful try { - BrowserContentsBase = Directory.EnumerateFileSystemEntries(Path.Combine(repoA.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoA.LocalPath)).ToCollection(); + BrowserContentsBase = ListBrowserEntries(Path.Combine(repoA.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoA.LocalPath)).ToCollection(); } catch (DirectoryNotFoundException) { @@ -2400,7 +2443,7 @@ private void SwitchCompareBrowserPath(FullyQualifiedGitHubRepoName baseRepo, Ful try { - BrowserContentsCompare = Directory.EnumerateFileSystemEntries(Path.Combine(repoB.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoB.LocalPath)).ToCollection(); + BrowserContentsCompare = ListBrowserEntries(Path.Combine(repoB.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoB.LocalPath)).ToCollection(); } catch (DirectoryNotFoundException) { @@ -2427,7 +2470,7 @@ private void SwitchRepoBrowserPath(FullyQualifiedGitHubRepoName baseRepo, Relati try { - BrowserContentsBase = Directory.EnumerateFileSystemEntries(Path.Combine(repoA.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoA.LocalPath)).ToCollection(); + BrowserContentsBase = ListBrowserEntries(Path.Combine(repoA.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoA.LocalPath)).ToCollection(); } catch (DirectoryNotFoundException) { diff --git a/ProjectDirector/ProjectDirector.csproj b/ProjectDirector/ProjectDirector.csproj index ed7d5ea..93314e6 100644 --- a/ProjectDirector/ProjectDirector.csproj +++ b/ProjectDirector/ProjectDirector.csproj @@ -18,6 +18,7 @@ + diff --git a/README.md b/README.md index 98d72c2..4b1b873 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ canonical version out to the rest. - **Persistent State**: Development directory, credentials, repository cache, and UI layout are saved between sessions. +The [app gallery](docs/gallery/README.md) shows the main views and dialogs as the application draws them today. + ## Requirements - .NET 9.0 diff --git a/tests/ProjectDirector.UITests/AssemblyInfo.cs b/tests/ProjectDirector.UITests/AssemblyInfo.cs new file mode 100644 index 0000000..4d9f24e --- /dev/null +++ b/tests/ProjectDirector.UITests/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +// ImGui keeps its context in global state, so only one harness can exist per process, and the +// application's settings and secret store seams are process-wide too. Sequential execution is a +// correctness requirement here, not a performance preference. +[assembly: DoNotParallelize] diff --git a/tests/ProjectDirector.UITests/Gallery/AppGallery.cs b/tests/ProjectDirector.UITests/Gallery/AppGallery.cs new file mode 100644 index 0000000..88a7ae8 --- /dev/null +++ b/tests/ProjectDirector.UITests/Gallery/AppGallery.cs @@ -0,0 +1,127 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.UITests.Gallery; + +using ktsu.ImGui.App.Testing; + +/// +/// Photographs the application for docs/gallery: one picture per +/// entry, and an index that captions them. +/// +/// +/// +/// These run as tests, so every pull request proves each picture can still be staged: a stage that +/// clicks a control which has moved or been renamed fails here rather than at the next regeneration +/// on main. Pictures are written to a temporary directory unless PROJECTDIRECTOR_GALLERY_OUT +/// names one, which is how the gallery workflow, and anyone regenerating by hand, sends them to +/// docs/gallery. +/// +/// +/// Each entry starts its own application over freshly seeded settings, so a picture never depends on +/// the one taken before it. +/// +/// +[TestClass] +public sealed class AppGallery +{ + /// The environment variable naming the directory the gallery is written to. + internal const string OutputVariable = "PROJECTDIRECTOR_GALLERY_OUT"; + + /// The display the gallery is drawn at. + internal static readonly (int Width, int Height) Display = (1440, 900); + + private static readonly Lazy TemporaryOutput = new(() => + Path.Combine(Path.GetTempPath(), $"projectdirector-gallery-out-{Guid.NewGuid():N}")); + + /// Gets or sets the context the test runner supplies. + public TestContext TestContext { get; set; } = null!; + + /// Gets every entry's name, one test case each. + public static IEnumerable EntryNames => GalleryCatalog.Entries.Select(entry => new object[] { entry.Name }); + + /// Gets the directory pictures are written to. + internal static string OutputDirectory => + Environment.GetEnvironmentVariable(OutputVariable) is string output && output.Length > 0 + ? Path.GetFullPath(output) + : TemporaryOutput.Value; + + /// Creates the repositories every picture is taken over. + /// The class's test context, unused. + [ClassInitialize] + public static void SeedRepositories(TestContext context) => GalleryRepositories.Seed(); + + /// + /// Removes the seeded repositories, and the temporary directory when pictures went there rather + /// than to a named one. + /// + [ClassCleanup] + public static void DeleteTemporaryOutput() + { + GalleryRepositories.Delete(); + if (TemporaryOutput.IsValueCreated && Directory.Exists(TemporaryOutput.Value)) + { + Directory.Delete(TemporaryOutput.Value, recursive: true); + } + } + + [TestMethod] + [DynamicData(nameof(EntryNames))] + public void Photograph(string name) + { + GalleryEntry entry = GalleryCatalog.Entries.Single(candidate => candidate.Name == name); + + using GalleryApp app = GalleryApp.Start(Display); + Assert.IsTrue(GalleryFonts.Load(), "ImGuiApp's own font could not be found, so the pictures would not look like the application."); + app.Harness.Mouse.MoveTo(-100f, -100f); + app.Harness.Step(2); + + entry.Stage(app); + + // A stage ends wherever its last click landed, so the pointer is parked again: a hover + // highlight left behind would be in the picture. + app.Harness.Mouse.MoveTo(-100f, -100f); + app.Harness.Step(2); + + Bitmap32 frame = app.Harness.Target; + Rectangle region = entry.Crop?.Invoke(app) ?? new Rectangle(0, 0, frame.Width, frame.Height); + Bitmap32 picture = Crop(frame, region); + + Directory.CreateDirectory(OutputDirectory); + string path = Path.Combine(OutputDirectory, entry.Slug + ".png"); + picture.SavePng(path); + TestContext.WriteLine($"Wrote {path} ({picture.Width}x{picture.Height})."); + } + + [TestMethod] + public void WriteTheIndex() + { + string[] slugs = [.. GalleryCatalog.Entries.Select(entry => entry.Slug)]; + Assert.HasCount(slugs.Length, slugs.Distinct(StringComparer.Ordinal), "Two gallery entries would write the same file."); + + Directory.CreateDirectory(OutputDirectory); + File.WriteAllText(Path.Combine(OutputDirectory, "README.md"), GalleryIndex.Render(GalleryCatalog.Entries)); + } + + /// Copies a rectangle out of a frame, clamped to its edges. + /// The frame. + /// The part of it to keep. + /// A new bitmap holding that part. + internal static Bitmap32 Crop(Bitmap32 source, Rectangle region) + { + int minX = Math.Clamp(region.MinX, 0, source.Width); + int minY = Math.Clamp(region.MinY, 0, source.Height); + int maxX = Math.Clamp(region.MaxX, minX, source.Width); + int maxY = Math.Clamp(region.MaxY, minY, source.Height); + Assert.IsTrue(maxX > minX && maxY > minY, $"The crop {region} leaves nothing of a {source.Width}x{source.Height} frame."); + + Bitmap32 cropped = new(maxX - minX, maxY - minY); + int rowBytes = cropped.Width * 4; + for (int y = minY; y < maxY; y++) + { + source.Pixels.Slice(((y * source.Width) + minX) * 4, rowBytes) + .CopyTo(cropped.Pixels.Slice((y - minY) * rowBytes, rowBytes)); + } + + return cropped; + } +} diff --git a/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs b/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs new file mode 100644 index 0000000..c8276b6 --- /dev/null +++ b/tests/ProjectDirector.UITests/Gallery/GalleryApp.cs @@ -0,0 +1,115 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.UITests.Gallery; + +using System.IO.Abstractions.TestingHelpers; + +using ktsu.AppDataStorage; +using ktsu.CredentialCache.Storage; +using ktsu.ImGui.App.Testing; + +using CredentialCache = ktsu.CredentialCache.CredentialCache; + +/// +/// The real application, started headlessly over the seeded repositories with its settings file +/// and secret store replaced by in-memory ones. +/// +/// +/// The application saves its settings as it starts and reads its GitHub token from the OS secret +/// store. Both are redirected for the lifetime of this object, so a gallery run never reads or +/// writes the settings or credentials of whoever runs it. +/// +internal sealed class GalleryApp : IDisposable +{ + private readonly CredentialCache credentials; + + private GalleryApp(ImGuiAppHarness harness, ProjectDirectorOptions options, CredentialCache credentials) + { + Harness = harness; + Options = options; + this.credentials = credentials; + } + + /// Gets the harness driving the application. + internal ImGuiAppHarness Harness { get; } + + /// Gets the settings the application is running on. + internal ProjectDirectorOptions Options { get; } + + /// Starts the application at the given display size. + /// The size of the window the application draws into. + /// The running application. + internal static GalleryApp Start((int Width, int Height) display) + { + AppData.ConfigureForTesting(() => new MockFileSystem()); + CredentialCache credentials = new(new InMemoryCredentialStore()); + TokenStorage.UseCache(credentials); + + try + { + ProjectDirectorOptions options = GalleryRepositories.CreateOptions(); + ProjectDirector director = new(options); + ImGuiAppHarness harness = ImGuiAppHarness.Start(director.BuildConfig(), new HarnessOptions + { + Width = display.Width, + Height = display.Height, + }); + + return new(harness, options, credentials); + } + catch + { + Restore(credentials); + throw; + } + } + + /// Selects a repository in the left panel and waits for its comparison to finish. + /// The repository's own name. + internal void SelectRepository(string repo) + { + Harness.Click($"repo/{repo}"); + GitRepository selected = Options.Repos[GalleryRepositories.FullName(repo)]; + + // The comparison runs git on a background task, which publishes whenever it finishes rather + // than after some number of frames, so the wait is on the task's own answer. + Assert.IsTrue( + SpinWait.SpinUntil(() => !selected.SimilarReposPending, TimeSpan.FromSeconds(60)), + $"Comparing {repo} against its siblings did not finish."); + Harness.Step(2); + } + + /// Right-clicks a probe-marked item, which is what opens a context menu. + /// The item's probe name. + internal void RightClick(string name) + { + Rectangle item = Harness.Probe.Rect(name) ?? throw new InvalidOperationException($"Nothing called {name} has been drawn."); + Harness.Mouse.Click(item.MinX + (item.Width / 2f), item.MinY + (item.Height / 2f), 1); + Harness.Step(2); + } + + /// + /// Gets everything above the log panel. The log prefixes each line with the wall-clock time it + /// was written, so it can never be photographed the same way twice. + /// + /// The part of the window worth keeping. + internal Rectangle AboveTheLog() + { + Rectangle log = Harness.Probe.Rect("Log") ?? throw new InvalidOperationException("The log panel has not been drawn."); + return new Rectangle(0, 0, Harness.Options.Width, log.MinY - 1); + } + + /// + public void Dispose() + { + Harness.Dispose(); + Restore(credentials); + } + + private static void Restore(CredentialCache credentials) + { + TokenStorage.UseCache(null); + credentials.Dispose(); + AppData.ResetFileSystem(); + } +} diff --git a/tests/ProjectDirector.UITests/Gallery/GalleryCatalog.cs b/tests/ProjectDirector.UITests/Gallery/GalleryCatalog.cs new file mode 100644 index 0000000..28bdc71 --- /dev/null +++ b/tests/ProjectDirector.UITests/Gallery/GalleryCatalog.cs @@ -0,0 +1,91 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.UITests.Gallery; + +using ktsu.ImGui.App.Testing; + +/// Every picture in the app gallery, in the order the index shows them. +/// +/// Each stage starts from the seeded repositories listed and nothing selected, and drives the +/// application by the probe names its own draw code records. A new view earns a picture by adding an +/// entry here; nothing else needs to change, because the runner, the index and the workflow all read +/// this list. +/// +internal static class GalleryCatalog +{ + /// Gets the entries. + internal static IReadOnlyList Entries { get; } = + [ + new( + "Selecting a repository", + "The repositories under each GitHub owner are listed on the left, marked green where a clone exists in the dev directory. Selecting one shows where it lives, its git actions, its files, and its siblings ranked by how many files they share with it.", + app => app.SelectRepository(GalleryRepositories.Alpha)) + { + Crop = AboveTheLog, + }, + new( + "Comparing two repositories", + "Picking a similar repository lists every shared file that differs between the two, with how many lines differ, beside a browser over both trees.", + app => + { + app.SelectRepository(GalleryRepositories.Alpha); + app.Harness.Click($"similar/{GalleryRepositories.FullName(GalleryRepositories.Beta)}"); + app.Harness.Step(3); + }) + { + Crop = AboveTheLog, + }, + new( + "Comparing a file", + "Picking a changed file shows the two copies side by side, and the arrows take a block from one side to the other.", + app => + { + app.SelectRepository(GalleryRepositories.Alpha); + app.Harness.Click($"similar/{GalleryRepositories.FullName(GalleryRepositories.Beta)}"); + app.Harness.Step(3); + app.Harness.Click($"changed/{GalleryRepositories.WidgetSource}"); + app.Harness.Step(3); + }) + { + Crop = AboveTheLog, + }, + new( + "Propagating a file", + "Right-clicking a file in the browser offers to copy it into any of the sibling repositories that also have it.", + app => + { + app.SelectRepository(GalleryRepositories.Alpha); + app.RightClick("browse/README.md"); + app.Harness.Click("browse-menu/Propagate"); + app.Harness.Step(3); + }) + { + Crop = AboveTheLog, + }, + new( + "Setting the dev directory", + "The directory scanned for clones is set from the File menu.", + app => OpenMenuItem(app, "menu/Set Dev Directory")) + { + Crop = AboveTheLog, + }, + new( + "Adding a GitHub owner", + "Repositories are discovered by GitHub owner, a user or an organization, added from the File menu.", + app => OpenMenuItem(app, "menu/Add New GitHub Owner")) + { + Crop = AboveTheLog, + }, + ]; + + private static Rectangle? AboveTheLog(GalleryApp app) => app.AboveTheLog(); + + /// Opens a popup from the File menu. The popup is only ever shown, never confirmed. + private static void OpenMenuItem(GalleryApp app, string item) + { + app.Harness.Click("menu/File"); + app.Harness.Step(2); + app.Harness.Click(item); + app.Harness.Step(3); + } +} diff --git a/tests/ProjectDirector.UITests/Gallery/GalleryEntry.cs b/tests/ProjectDirector.UITests/Gallery/GalleryEntry.cs new file mode 100644 index 0000000..8f9fc41 --- /dev/null +++ b/tests/ProjectDirector.UITests/Gallery/GalleryEntry.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.UITests.Gallery; + +using System.Text; + +using ktsu.ImGui.App.Testing; + +/// One picture in the app gallery: how to stage it, and what to say about it. +/// The caption, which also names the picture's file. +/// One or two sentences under the picture in the gallery's index. +/// +/// Drives the application into the state worth photographing, starting from the seeded repositories +/// listed and nothing selected. It works the way a user would, through menus, panels and the +/// pointer, so the picture is of a path through the application rather than of state set behind +/// its back. +/// +internal sealed record GalleryEntry(string Name, string Description, Action Stage) +{ + /// + /// Gets the part of the window to keep, or null for all of it. Asked after + /// has run, so it can measure what the stage put on screen. + /// + public Func? Crop { get; init; } + + /// Gets the file name the picture is written under, without its extension. + public string Slug => MakeSlug(Name); + + /// + public override string ToString() => Name; + + /// Turns a caption into a lower-case, hyphenated file name. + internal static string MakeSlug(string text) + { + StringBuilder slug = new(text.Length); + foreach (char character in text) + { + if (char.IsAsciiLetterOrDigit(character)) + { + slug.Append(char.ToLowerInvariant(character)); + } + else if (slug.Length > 0 && slug[^1] != '-') + { + slug.Append('-'); + } + } + + return slug.ToString().TrimEnd('-'); + } +} diff --git a/tests/ProjectDirector.UITests/Gallery/GalleryFonts.cs b/tests/ProjectDirector.UITests/Gallery/GalleryFonts.cs new file mode 100644 index 0000000..58f84d5 --- /dev/null +++ b/tests/ProjectDirector.UITests/Gallery/GalleryFonts.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.UITests.Gallery; + +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Resources; + +using Hexa.NET.ImGui; + +using ktsu.ImGui.App; + +/// Gives the gallery the font a running application draws in. +/// +/// The harness never builds ImGuiApp's own fonts, so a headless test draws in Dear ImGui's +/// built-in bitmap font. That is right for a test, which must not move when a font does, and wrong +/// for pictures of the application. This loads the Nerd Font ImGuiApp ships +/// and makes it the default, which is what the widget gallery in ImGuiApp does for the same reason. +/// +internal static class GalleryFonts +{ + /// The size ImGuiApp draws its interface at, at a scale of one. + private const float Pixels = 14f; + + private static readonly Lazy NerdFont = new(() => + { + try + { + ResourceManager resources = new("ktsu.ImGui.App.Resources.Resources", typeof(ImGuiApp).Assembly); + return resources.GetObject("NerdFont", CultureInfo.InvariantCulture) as byte[]; + } + catch (MissingManifestResourceException) + { + return null; + } + }); + + /// Gets a value indicating whether the application's font could be found. + internal static bool Available => NerdFont.Value is not null; + + /// Adds the application's font and makes it the default. Call between frames. + /// True when the font was loaded; false leaves the built-in font in place. + [SuppressMessage("Major Code Smell", "S6640:Make sure that using \"unsafe\" is safe here", Justification = "FontHelper's glyph range helper returns a pointer ImGui owns for the atlas's lifetime; it is passed straight through and never dereferenced here.")] + internal static bool Load() + { + if (NerdFont.Value is not byte[] data) + { + return false; + } + + ImGuiIOPtr io = ImGui.GetIO(); + unsafe + { + ImFontPtr? font = FontHelper.AddCustomFont(io, data, Pixels, FontHelper.GetExtendedUnicodeRanges(io.Fonts)); + if (font is not ImFontPtr loaded) + { + return false; + } + + io.FontDefault = loaded; + } + + return true; + } +} diff --git a/tests/ProjectDirector.UITests/Gallery/GalleryIndex.cs b/tests/ProjectDirector.UITests/Gallery/GalleryIndex.cs new file mode 100644 index 0000000..bf0b2ba --- /dev/null +++ b/tests/ProjectDirector.UITests/Gallery/GalleryIndex.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.UITests.Gallery; + +using System.Text; + +/// Writes the gallery's README.md, which captions each picture. +internal static class GalleryIndex +{ + /// Renders the index for the given entries, in catalog order. + /// The entries to caption. + /// The index's text, with LF line endings. + internal static string Render(IReadOnlyList entries) + { + StringBuilder text = new(); + text.Append("# App gallery\n\n"); + text.Append("\n\n"); + text.Append("Project Director as it draws today, staged and photographed headlessly by the UI test harness "); + text.Append("over three small repositories seeded for the purpose. "); + text.Append("The pictures are regenerated on every successful build of `main`, so they show the current application. "); + text.Append("The log panel along the bottom of the window is left out of every picture, because it stamps each line with the time it was written.\n\n"); + + foreach (GalleryEntry entry in entries) + { + text.Append("## ").Append(entry.Name).Append("\n\n"); + text.Append(entry.Description).Append("\n\n"); + text.Append("![").Append(entry.Name).Append("](").Append(entry.Slug).Append(".png)\n\n"); + } + + return text.ToString(); + } +} diff --git a/tests/ProjectDirector.UITests/Gallery/GalleryRepositories.cs b/tests/ProjectDirector.UITests/Gallery/GalleryRepositories.cs new file mode 100644 index 0000000..89336a9 --- /dev/null +++ b/tests/ProjectDirector.UITests/Gallery/GalleryRepositories.cs @@ -0,0 +1,251 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ProjectDirector.UITests.Gallery; + +using System.Diagnostics; + +using ktsu.Semantics.Paths; + +/// +/// Seeds the three sibling repositories the gallery photographs, and the settings that point the +/// application at them. +/// +/// +/// +/// The repositories are real git repositories, made offline with fixed names, dates and contents, +/// because the comparison the application draws runs git against them. Nothing here touches +/// a remote: the settings carry each repository's GitHub address only as text to show, and every +/// fetch interval is zero, which is how the application spells "never fetch on a timer". +/// +/// +/// They live at one fixed path rather than a fresh temporary one, because the application prints +/// the dev directory and the selected repository's local path, and a path that changed per run +/// would change every picture with it. +/// +/// +internal static class GalleryRepositories +{ + /// The GitHub owner the repositories are filed under. + internal const string Owner = "ktsu-dev"; + + /// The repository the gallery selects. + internal const string Alpha = "Alpha"; + + /// The sibling sharing the most files with . + internal const string Beta = "Beta"; + + /// A sibling sharing fewer files with . + internal const string Gamma = "Gamma"; + + /// The file the diff pictures compare, which differs in several places. + internal const string WidgetSource = "src/Widget.cs"; + + /// + /// The repositories in the order the settings list them. Order matters: the similar repos table + /// breaks ties by the order the settings hold the siblings in. + /// + private static readonly string[] Names = [Alpha, Beta, Gamma]; + + /// Gets the directory every repository is created under, which the application shows as its dev directory. + internal static string Root => OperatingSystem.IsWindows() + ? Path.Combine(Path.GetTempPath(), "projectdirector-gallery") + : "/tmp/projectdirector-gallery"; + + /// Gets the name the application keys a repository by. + /// The repository's own name. + /// The owner-qualified name. + internal static FullyQualifiedGitHubRepoName FullName(string repo) => + FullyQualifiedGitHubRepoName.Create($"{Owner}.{repo}"); + + /// Creates the repositories, replacing any left by an earlier run. + internal static void Seed() + { + Delete(); + + Write(Alpha, "README.md", """ + # Alpha + + A small library in the ktsu-dev family. + + ## Usage + + Install the package and call `Widget.Build()`. + + ## License + + MIT + """); + Write(Alpha, ".editorconfig", EditorConfig); + Write(Alpha, "LICENSE.md", License); + Write(Alpha, WidgetSource, """ + namespace Alpha; + + public static class Widget + { + public static string Build() => "alpha"; + + public static int Size => 3; + } + """); + + Write(Beta, "README.md", """ + # Beta + + A small library in the ktsu-dev family. + + ## Usage + + Install the package and call `Widget.Build()`. + + ## License + + MIT + """); + Write(Beta, ".editorconfig", EditorConfig); + Write(Beta, "LICENSE.md", License); + Write(Beta, WidgetSource, """ + namespace Beta; + + public static class Widget + { + public static string Build() => "beta"; + + public static int Size => 4; + + public static bool IsReady => true; + } + """); + + Write(Gamma, "README.md", """ + # Gamma + + A command line tool in the ktsu-dev family. + + ## Usage + + Run `gamma --help` for the list of commands. + + ## License + + MIT + """); + Write(Gamma, ".editorconfig", EditorConfig); + Write(Gamma, "LICENSE.md", License); + Write(Gamma, "docs/commands.md", """ + # Commands + + - `gamma build` + - `gamma check` + """); + + foreach (string name in Names) + { + string path = PathOf(name); + Git(path, "init", "--quiet", "--initial-branch=main"); + Git(path, "add", "--all"); + Git(path, "commit", "--quiet", "--no-gpg-sign", "--message", "Initial commit"); + } + } + + /// Removes the repositories. + internal static void Delete() + { + if (Directory.Exists(Root)) + { + Directory.Delete(Root, recursive: true); + } + } + + /// + /// Builds the settings the application starts from: one owner, its three repositories, all + /// recorded as cloned, and nothing selected. + /// + /// Fresh settings, which the application is free to modify. + internal static ProjectDirectorOptions CreateOptions() + { + GitHubOwnerName owner = GitHubOwnerName.Create(Owner); + ProjectDirectorOptions options = new() + { + DevDirectory = AbsoluteDirectoryPath.Create(Root), + }; + + _ = options.GitHubOwners.Add(owner); + foreach (string name in Names) + { + GitHubRepository repo = new() + { + OwnerName = owner, + RepoName = GitHubRepoName.Create(name), + LocalPath = FullyQualifiedLocalRepoPath.Create(PathOf(name)), + RemotePath = GitRemotePath.Create($"https://github.com/{Owner}/{name}"), + MinFetchIntervalSeconds = 0, + }; + + options.Repos[FullName(name)] = repo; + options.ClonedRepos[repo.LocalPath] = FullName(name); + } + + return options; + } + + private const string EditorConfig = """ + root = true + + [*] + indent_style = tab + end_of_line = lf + """; + + private const string License = """ + MIT License + + Copyright (c) ktsu-dev contributors + """; + + private static string PathOf(string repo) => Path.Combine(Root, Owner, repo); + + private static void Write(string repo, string relativePath, string content) + { + string path = Path.Combine(PathOf(repo), relativePath); + _ = Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content.ReplaceLineEndings("\n") + "\n"); + } + + /// + /// Runs git with every input that could vary between machines pinned: no system or user + /// configuration, and a fixed author, committer and date. + /// + private static void Git(string workingDirectory, params string[] arguments) + { + ProcessStartInfo start = new("git") + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + + start.Environment["GIT_CONFIG_NOSYSTEM"] = "1"; + start.Environment["GIT_CONFIG_GLOBAL"] = OperatingSystem.IsWindows() ? "NUL" : "/dev/null"; + start.Environment["GIT_AUTHOR_NAME"] = "ktsu-dev gallery"; + start.Environment["GIT_AUTHOR_EMAIL"] = "gallery@ktsu.dev"; + start.Environment["GIT_AUTHOR_DATE"] = "2026-01-01T00:00:00Z"; + start.Environment["GIT_COMMITTER_NAME"] = "ktsu-dev gallery"; + start.Environment["GIT_COMMITTER_EMAIL"] = "gallery@ktsu.dev"; + start.Environment["GIT_COMMITTER_DATE"] = "2026-01-01T00:00:00Z"; + + using Process git = Process.Start(start) ?? throw new InvalidOperationException("git could not be started."); + string error = git.StandardError.ReadToEnd(); + _ = git.StandardOutput.ReadToEnd(); + git.WaitForExit(); + if (git.ExitCode != 0) + { + throw new InvalidOperationException($"git {string.Join(' ', arguments)} failed in {workingDirectory}: {error}"); + } + } +} diff --git a/tests/ProjectDirector.UITests/ProjectDirector.UITests.csproj b/tests/ProjectDirector.UITests/ProjectDirector.UITests.csproj new file mode 100644 index 0000000..50b482f --- /dev/null +++ b/tests/ProjectDirector.UITests/ProjectDirector.UITests.csproj @@ -0,0 +1,24 @@ + + + + + + true + net10.0 + + + ktsu.ProjectDirector.UITests + $(RootNamespace) + false + true + + + + + + + + + From d17fe21fd008ce80cf7439218043c018e330018c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:36:10 +0000 Subject: [PATCH 2/2] Join browse paths without letting a rooted segment drop the repository Path.Join treats a second segment that begins with a separator as part of the path instead of discarding the repository directory before it, which Path.Combine does. The browse path is relative, so nothing changes for it today. The gallery fixture also maps repository names to paths in the loop source rather than inside the loop body. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WWRBkw7bXdWGzuLpqnCvrB --- ProjectDirector/ProjectDirector.cs | 6 +++--- .../ProjectDirector.UITests/Gallery/GalleryRepositories.cs | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/ProjectDirector/ProjectDirector.cs b/ProjectDirector/ProjectDirector.cs index 831b6ad..138a267 100644 --- a/ProjectDirector/ProjectDirector.cs +++ b/ProjectDirector/ProjectDirector.cs @@ -2434,7 +2434,7 @@ private void SwitchCompareBrowserPath(FullyQualifiedGitHubRepoName baseRepo, Ful try { - BrowserContentsBase = ListBrowserEntries(Path.Combine(repoA.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoA.LocalPath)).ToCollection(); + BrowserContentsBase = ListBrowserEntries(Path.Join(repoA.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoA.LocalPath)).ToCollection(); } catch (DirectoryNotFoundException) { @@ -2443,7 +2443,7 @@ private void SwitchCompareBrowserPath(FullyQualifiedGitHubRepoName baseRepo, Ful try { - BrowserContentsCompare = ListBrowserEntries(Path.Combine(repoB.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoB.LocalPath)).ToCollection(); + BrowserContentsCompare = ListBrowserEntries(Path.Join(repoB.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoB.LocalPath)).ToCollection(); } catch (DirectoryNotFoundException) { @@ -2470,7 +2470,7 @@ private void SwitchRepoBrowserPath(FullyQualifiedGitHubRepoName baseRepo, Relati try { - BrowserContentsBase = ListBrowserEntries(Path.Combine(repoA.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoA.LocalPath)).ToCollection(); + BrowserContentsBase = ListBrowserEntries(Path.Join(repoA.LocalPath, Options.BrowsePath)).Select(x => formatPath(x, repoA.LocalPath)).ToCollection(); } catch (DirectoryNotFoundException) { diff --git a/tests/ProjectDirector.UITests/Gallery/GalleryRepositories.cs b/tests/ProjectDirector.UITests/Gallery/GalleryRepositories.cs index 89336a9..12cd945 100644 --- a/tests/ProjectDirector.UITests/Gallery/GalleryRepositories.cs +++ b/tests/ProjectDirector.UITests/Gallery/GalleryRepositories.cs @@ -138,9 +138,8 @@ A command line tool in the ktsu-dev family. - `gamma check` """); - foreach (string name in Names) + foreach (string path in Names.Select(PathOf)) { - string path = PathOf(name); Git(path, "init", "--quiet", "--initial-branch=main"); Git(path, "add", "--all"); Git(path, "commit", "--quiet", "--no-gpg-sign", "--message", "Initial commit");