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..138a267 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.Join(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.Join(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.Join(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