Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 3 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,7 @@ jobs:
- name: Terminal.Gui.Editor.IntegrationTests
run: dotnet run --project tests/Terminal.Gui.Editor.IntegrationTests --no-build

# Performance gate: run focused benchmarks on Linux only, compare to baseline.
# Fails on >3x regression; celebrates >20% improvement.
- name: Performance check
if: matrix.os == 'ubuntu-latest'
run: |
OUTPUT=$(bash benchmarks/compare-baseline.sh 3.0 0.8 2>&1) || PERF_FAILED=1
echo "$OUTPUT"
echo "$OUTPUT" >> "$GITHUB_STEP_SUMMARY"
if [ "${PERF_FAILED:-0}" -eq 1 ]; then
exit 1
fi
# NOTE: Performance smoke tests and the BenchmarkDotNet baseline compare live in a
# dedicated workflow: .github/workflows/perf.yml (ubuntu-latest only). They were split
# out so this CI workflow stays purely correctness-focused and fast across all three OSes.

82 changes: 82 additions & 0 deletions .github/workflows/perf.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Performance

# Performance jobs run on ubuntu-latest only. Windows / macOS GitHub-hosted runners share
# hosts with neighbour VMs, so their wall-time measurements are too noisy to gate on; Linux
# runners are still noisy but consistent enough that a 3× regression is a real signal.
#
# The smoke-test step runs on every push / PR. The benchmark-compare step runs the same way
# but only the gated *VisualLineBuild* filter — full BenchmarkDotNet runs (Scrolling,
# EndToEndScroll, CaretMovement, DocumentAccess) are too slow for per-PR CI and live behind
# the manual `workflow_dispatch` trigger for occasional baseline refreshes.

on:
push:
branches: ['**']
pull_request:
branches: [main, develop]
workflow_dispatch:
inputs:
full-suite:
description: 'Run the full BenchmarkDotNet suite (all categories, not just the gated filter). Output is uploaded as an artifact.'
type: boolean
default: false

permissions:
contents: read
pull-requests: write

jobs:
perf:
runs-on: ubuntu-latest

env:
DisableRealDriverIO: "1"

steps:
- uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
dotnet-quality: 'preview'

- name: Restore
run: dotnet restore Terminal.Gui.Editor.slnx

# Smoke tests are stopwatch-based (PerformanceSmokeTests.cs) — Release config matters
# because Debug-mode timings would force the thresholds to be useless.
- name: Build (Release)
run: dotnet build Terminal.Gui.Editor.slnx -c Release --no-restore

- name: Terminal.Gui.Editor.PerformanceTests (smoke)
run: dotnet run -c Release --project tests/Terminal.Gui.Editor.PerformanceTests --no-build

# Gated benchmark compare. Fails the job on >3× regression vs. benchmarks/baseline.json,
# celebrates <0.8× improvements in the step summary. Uses the lowercase `--job short`
# form BenchmarkDotNet actually accepts (see compare-baseline.sh for the bug history).
- name: Benchmark gate — VisualLineBuild vs. baseline
run: |
OUTPUT=$(bash benchmarks/compare-baseline.sh 3.0 0.8 2>&1) || PERF_FAILED=1
echo "$OUTPUT"
echo "$OUTPUT" >> "$GITHUB_STEP_SUMMARY"
if [ "${PERF_FAILED:-0}" -eq 1 ]; then
exit 1
fi

# Full BenchmarkDotNet run is opt-in via workflow_dispatch. It uploads the results
# directory so the operator can pick numbers for baseline.json refreshes (see #78).
- name: Full benchmark suite (manual)
if: github.event_name == 'workflow_dispatch' && inputs.full-suite
run: |
dotnet run -c Release --no-build \
--project benchmarks/Terminal.Gui.Editor.Benchmarks \
-- --job short --exporters json,html --artifacts BenchmarkDotNet.Artifacts

- name: Upload BenchmarkDotNet artifacts (manual full run only)
if: github.event_name == 'workflow_dispatch' && inputs.full-suite
uses: actions/upload-artifact@v4
with:
name: benchmark-results-${{ github.sha }}
path: BenchmarkDotNet.Artifacts/
retention-days: 30
24 changes: 19 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,13 @@ Tests are xUnit.v3 and run as **executables** (each test project sets `<OutputTy
dotnet run --project tests/Terminal.Gui.Editor.Tests
dotnet run --project tests/Terminal.Gui.Editor.Tests
dotnet run --project tests/Terminal.Gui.Editor.IntegrationTests
dotnet run --project tests/Terminal.Gui.Editor.PerformanceTests -c Release
```

The `PerformanceTests` project is stopwatch-based and only meaningful in Release. It runs in
the dedicated `.github/workflows/perf.yml` workflow (ubuntu-latest only), separately from the
correctness-focused `ci.yml`. See the "Testing tiers" section below.

Run a single test by passing xUnit.v3 filter args after `--`:

```sh
Expand Down Expand Up @@ -165,16 +170,25 @@ private void ExtendCaretBy (int delta)

## Testing tiers

Three test projects, mirroring Terminal.Gui's convention. **All three run fully in parallel** — Terminal.Gui's `Application` lifetime is per-instance (`Application.Create()` returns an `IApplication` whose `Init`/`Begin`/`End`/`Dispose` track via `ThreadLocal<>`, not process globals). Tests must never call the static `Application.Init()` shortcut, and must never enable `ConfigurationManager` (`CM.Enable(...)`) — both reach for process-global state and would force serialization.
Four test projects, mirroring Terminal.Gui's convention. **The correctness projects all run fully in parallel** — Terminal.Gui's `Application` lifetime is per-instance (`Application.Create()` returns an `IApplication` whose `Init`/`Begin`/`End`/`Dispose` track via `ThreadLocal<>`, not process globals). Tests must never call the static `Application.Init()` shortcut, and must never enable `ConfigurationManager` (`CM.Enable(...)`) — both reach for process-global state and would force serialization.

- `Terminal.Gui.Editor.Tests` — pure, no UI, no static state. Target ≥90% coverage.
- `Terminal.Gui.Editor.Tests` — visual-line builder, wrap, caret/selection math, command handlers — anything that doesn't need an `IApplication`. Target ≥75%.
- `Terminal.Gui.Editor.IntegrationTests` — full key-input → render scenarios via `AppFixture<T>`, which boots a per-test `IApplication` from `Application.Create()`. Parallel by default.
- `Terminal.Gui.Editor.Tests` — pure, no UI, no static state. Target ≥90% coverage. Runs in `ci.yml`.
- `Terminal.Gui.Editor.IntegrationTests` — full key-input → render scenarios via `AppFixture<T>`, which boots a per-test `IApplication` from `Application.Create()`. Parallel by default. Runs in `ci.yml`.
- `Terminal.Gui.Editor.PerformanceTests` — stopwatch-based perf smoke tests. **Release only, ubuntu-latest only.** Lives in its own project and its own workflow (`.github/workflows/perf.yml`) because Windows/macOS GitHub-hosted runners are too noisy for wall-time assertions. The BenchmarkDotNet suite in `benchmarks/` runs from the same workflow.

New tests default to the parallel-by-name project. Promote to `IntegrationTests` only when an `IApplication` (driver, input injection, full layout/draw) is genuinely needed.
New tests default to the parallel-by-name project. Promote to `IntegrationTests` only when an `IApplication` (driver, input injection, full layout/draw) is genuinely needed. Promote to `PerformanceTests` only when you need a wall-time assertion — and remember it won't run on Windows/macOS CI, so don't put correctness checks there.

**The one allowed exception:** a test that legitimately mutates a process-global (e.g. `Logging.Logger`, `Trace.EnabledCategories`, anything `static`) must opt out of cross-collection parallelism via a `[CollectionDefinition(name, DisableParallelization = true)]` + `[Collection(name)]` pair. See `tests/Terminal.Gui.Editor.IntegrationTests/HostingTests.cs` for the canonical example. Do **not** add an assembly-wide `xunit.runner.json` to make the whole project serial — that's the wrong tool for one offending class.

### Performance gates

Two layers, both in `.github/workflows/perf.yml`:

1. **`Terminal.Gui.Editor.PerformanceTests`** — stopwatch smoke tests with deliberately loose thresholds (~5× typical wall time). They catch catastrophic regressions, not 10% drift.
2. **`benchmarks/compare-baseline.sh`** — runs the focused `*VisualLineBuild*` BenchmarkDotNet filter and compares against `benchmarks/baseline.json`. Fails on >3× regression, celebrates on <0.8× improvement. **Run `--job short`** (lowercase) — `ShortRun` makes BDN reject it and the comparison silently no-ops.

The full BenchmarkDotNet matrix (`Scrolling`, `EndToEndScroll`, `CaretMovement`, `DocumentAccess`) is opt-in via `workflow_dispatch` on the perf workflow with `full-suite: true`. That's the operator path for refreshing `baseline.json` — run, download artifact, commit the numbers.

### Diagnosing parallel-test hangs

When integration tests run individually but hang when run as a suite, **the cause is almost always shared mutable state, not the parallelism itself**. Do not reach for `xunit.runner.json` with `parallelizeTestCollections: false` to "fix" it — that hides the bug and slows the suite. Walk this checklist instead:
Expand Down
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ Pre-alpha — see [`specs/00-plan.md`](specs/00-plan.md) for the full implementa
## Repository layout

```
specs/ Planning and design docs
src/ Terminal.Gui.Editor library (document layer + Editor view)
tests/ xUnit.v3 test projects
examples/ ted — standalone demo app
specs/ Planning and design docs
src/ Terminal.Gui.Editor library (document layer + Editor view)
tests/ xUnit.v3 test projects (correctness + perf smoke)
benchmarks/ BenchmarkDotNet suite + CI baseline
examples/ ted — standalone demo app
```

## Build
Expand All @@ -31,8 +32,14 @@ Requires the .NET 10 SDK (preview).
```sh
dotnet restore Terminal.Gui.Editor.slnx
dotnet build Terminal.Gui.Editor.slnx

# Correctness suites — run on every push/PR across ubuntu/macos/windows.
dotnet run --project tests/Terminal.Gui.Editor.Tests
dotnet run --project tests/Terminal.Gui.Editor.IntegrationTests

# Perf smoke + BenchmarkDotNet baseline gate — ubuntu-latest only in CI
# (.github/workflows/perf.yml). Run locally in Release config.
dotnet run --project tests/Terminal.Gui.Editor.PerformanceTests -c Release
```

Run the demo:
Expand Down
2 changes: 2 additions & 0 deletions Terminal.Gui.Editor.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<File Path=".editorconfig" />
<File Path=".gitattributes" />
<File Path=".github/workflows/ci.yml" />
<File Path=".github/workflows/perf.yml" />
<File Path=".github/workflows/release.yml" />
<File Path=".gitignore" />
<File Path="Directory.Build.props" />
Expand All @@ -30,5 +31,6 @@
<Project Path="examples/ted/ted.csproj" />
<Project Path="src/Terminal.Gui.Editor/Terminal.Gui.Editor.csproj" />
<Project Path="tests/Terminal.Gui.Editor.IntegrationTests/Terminal.Gui.Editor.IntegrationTests.csproj" />
<Project Path="tests/Terminal.Gui.Editor.PerformanceTests/Terminal.Gui.Editor.PerformanceTests.csproj" />
<Project Path="tests/Terminal.Gui.Editor.Tests/Terminal.Gui.Editor.Tests.csproj" />
</Solution>
8 changes: 6 additions & 2 deletions benchmarks/compare-baseline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BASELINE="$SCRIPT_DIR/baseline.json"
RESULTS_DIR="$(mktemp -d)"

echo "::group::Running focused benchmarks (ShortRun)"
echo "::group::Running focused benchmarks (short job)"
# BenchmarkDotNet accepts only lowercase job names: default, dry, short, medium, long, verylong.
# Passing "ShortRun" makes BDN print "invalid base job" and exit without running anything; the
# script then sees no JSON report and falls into "skipping comparison" → exit 0 → silent no-op.
# This was the gate's bug for everything between PR #53 and PR #77.
dotnet run --project "$SCRIPT_DIR/Terminal.Gui.Editor.Benchmarks" -c Release -- \
--filter "*VisualLineBuild*" \
--job ShortRun \
--job short \
--exporters json \
--artifacts "$RESULTS_DIR" 2>&1 | tail -20
echo "::endgroup::"
Expand Down
18 changes: 10 additions & 8 deletions specs/constitution.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,17 +137,19 @@ The fork is **hard** — re-syncs are manual and deliberate, triggered only by u

## VI. Testing Tiers

Three test projects mirroring Terminal.Gui's convention:
Four test projects mirroring Terminal.Gui's convention:

| Project | Parallel | Purpose | Coverage Target |
|---------|----------|---------|-----------------|
| `Terminal.Gui.Editor.Tests` | | Pure, no UI, no static state | ≥ 90% |
| `Terminal.Gui.Editor.Tests` | | Visual-line builder, wrap, caret/selection, commands | ≥ 75% |
| `Terminal.Gui.Editor.IntegrationTests` | | Full key-input → render via `AppFixture<T>` (per-test `IApplication.Create()`) | Informational |
| Project | Workflow | OS matrix | Purpose | Coverage Target |
|---------|----------|-----------|---------|-----------------|
| `Terminal.Gui.Editor.Tests` | `ci.yml` | ubuntu, macos, windows | Pure, no UI, no static state | ≥ 90% |
| `Terminal.Gui.Editor.IntegrationTests` | `ci.yml` | ubuntu, macos, windows | Full key-input → render via `AppFixture<T>` (per-test `IApplication.Create()`) | Informational |
| `Terminal.Gui.Editor.PerformanceTests` | `perf.yml` | ubuntu only, Release only | Stopwatch perf smoke tests + BenchmarkDotNet baseline gate | Wall-time guards |

The lone exception: a class that mutates a process-global static (e.g. `HostingTests`) opts out of cross-collection parallelism via `[CollectionDefinition(..., DisableParallelization = true)]` + `[Collection(...)]`. Never disable parallelism at the assembly level.
The three correctness projects run **in parallel** within each assembly. The lone exception: a class that mutates a process-global static (e.g. `HostingTests`) opts out of cross-collection parallelism via `[CollectionDefinition(..., DisableParallelization = true)]` + `[Collection(...)]`. Never disable parallelism at the assembly level.

Tests run as **executables** (xUnit.v3): `dotnet run --project tests/<project>`.
`PerformanceTests` lives in its own workflow because Windows / macOS GitHub-hosted runners share hosts with neighbour VMs — wall-time assertions there are too noisy to gate on. Ubuntu runners are still noisy but consistent enough that a >3× BenchmarkDotNet baseline ratio is a real signal. The full BenchmarkDotNet matrix runs only on `workflow_dispatch` (`full-suite: true`); that's the operator path for refreshing `benchmarks/baseline.json`.

Tests run as **executables** (xUnit.v3): `dotnet run --project tests/<project>`. `PerformanceTests` always runs with `-c Release`.

## VII. Coding Standards

Expand Down
17 changes: 10 additions & 7 deletions specs/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,17 @@ src/Terminal.Gui.Editor/ # the View
Editor.cs / .Drawing / .Keyboard / .Mouse / .Selection / .Commands
Rendering/ # rendering pipeline types
tests/
Terminal.Gui.Editor.Tests/ (parallel, pure)
Terminal.Gui.Editor.Tests/ (parallel, logic)
Terminal.Gui.Editor.IntegrationTests/ (parallel, per-test IApplication.Create())
Terminal.Gui.Editor.Tests/ (parallel, pure) — ci.yml
Terminal.Gui.Editor.IntegrationTests/ (parallel, IApplication) — ci.yml
Terminal.Gui.Editor.PerformanceTests/(stopwatch perf smoke) — perf.yml (ubuntu, Release only)
benchmarks/
Terminal.Gui.Editor.Benchmarks/ (BenchmarkDotNet suite) — perf.yml
baseline.json (gated metrics)
compare-baseline.sh (CI compare script)
examples/
ted/ (TG demo app)
EditorBenchmarks/ (placeholder)
ted/ (TG demo app)
third_party/AvaloniaEdit/
LICENSE UPSTREAM.md (commit d7a6b63 pinned)
LICENSE UPSTREAM.md (commit d7a6b63 pinned)
```

## Dependencies
Expand Down Expand Up @@ -104,7 +107,7 @@ Each criterion is testable. This is the merge-to-`main` gate.

- [ ] All features merged: drawing-overhaul, caret-anchors, read-only, find-and-replace, auto-indent, folding-ui, syntax-colorizer, word-wrap, multi-caret, clipboard.
- [ ] `dotnet build Terminal.Gui.Editor.slnx` clean on Linux/macOS/Windows on net10.0.
- [ ] All three test projects pass. Coverage: `Text.Tests` ≥ 90%, `Editor.Tests` ≥ 75%.
- [ ] All test projects pass. Coverage: `Editor.Tests` ≥ 90%. `PerformanceTests` smoke tests + the `*VisualLineBuild*` BenchmarkDotNet gate stay within 3× of `benchmarks/baseline.json`.
- [ ] `Editor.OnDrawingContent` does not iterate `text` by `char`. R1, R2, R4, R5 hold.
- [ ] `Editor.TabWidth`, `Editor.SyntaxHighlighter`, `Editor.SyntaxLanguage` all removed.
- [ ] No file under `src/Terminal.Gui.Editor/` references `Terminal.Gui`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
using Xunit;
using Attribute = Terminal.Gui.Drawing.Attribute;

namespace Terminal.Gui.Editor.Tests;
namespace Terminal.Gui.Editor.PerformanceTests;

/// <summary>
/// Stopwatch-based performance smoke tests that run in normal CI. Thresholds are set to
/// ~5x the typical wall time on an M-series Mac, so they only fail on catastrophic
/// regressions — not CI-runner noise. For precision measurements use the BenchmarkDotNet
/// suite in <c>benchmarks/</c>.
/// Stopwatch-based performance smoke tests. Lives in its own csproj and is driven by the
/// dedicated <c>.github/workflows/perf.yml</c> workflow on ubuntu-latest only — Windows /
/// macOS GitHub-hosted runners are too noisy for meaningful timing assertions. Thresholds
/// are deliberately loose (~5× typical wall time on a fast machine) so they only fail on
/// catastrophic regressions, not CI jitter. For precision measurements use the
/// BenchmarkDotNet suite in <c>benchmarks/</c>.
/// </summary>
public class PerformanceSmokeTests
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>Terminal.Gui.Editor.PerformanceTests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!--
Perf-flavored tests are only meaningful in a Release build. The dedicated workflow
(.github/workflows/perf.yml) always runs this project with -c Release. A Debug
build still compiles fine — assertion thresholds are conservative enough to pass —
but the numbers in any failure message are not Release-representative.
-->
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="xunit.v3" Version="3.2.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Terminal.Gui.Editor\Terminal.Gui.Editor.csproj" />
</ItemGroup>

</Project>
Loading