diff --git a/.agents/skills/tessera-contributing/SKILL.md b/.agents/skills/tessera-contributing/SKILL.md new file mode 100644 index 00000000..39f5cc6b --- /dev/null +++ b/.agents/skills/tessera-contributing/SKILL.md @@ -0,0 +1,56 @@ +--- +name: tessera-contributing +description: Use Tessera's own demos and developer tools while working in this repository. Use when running `mise run dev:*`, launching or snapshot-testing a demo, adding a demo, or fixing a library defect a demo shows. Read with the `tessera` skill, which it extends. +version: 0.1.0 +--- + +# Contributing to Tessera with the developer tools + +Everything in the [`tessera` skill](../tessera/SKILL.md) applies here: the `tessera` commands, the exit codes, and the pty recipe. This skill adds what only a Tessera contributor has: the `mise` tasks, the demos, and the demo snapshot tests. [Developer tooling](../../../docs/architecture/developer-tooling.md) is the design behind the tools. + +## Commands + +| mise | tessera | Does | +| --- | --- | --- | +| `mise run dev:apps` | `tessera apps` | List the registered demos: name, minimum size, viewports. | +| `mise run dev:apps --json` or `--pretty` | `tessera apps --json` or `--pretty` | Force the JSON object or the text table. | +| `mise run dev:launch NAME` | `tessera launch NAME` | Run one demo in this terminal until it quits. | +| `mise run dev:launch` | `tessera help launch`, then `tessera apps` | With no name, print the command's help and the demos. At a terminal that is the answer and the task exits 0; in a pipe or a script nothing ran and it exits 2, as `tessera` does. | + +`mise run dev:*` builds the `tessera` executable once, quietly (`swift build --quiet --package-path Examples --product tessera`), and runs the binary it built, so a build that succeeds prints nothing and one that fails still prints its errors; the binary keeps the task's terminal. It is the product of `Examples/Sources/TesseraDev`, whose registry is `Demos.registry()` from `Examples/Sources/DemoSupport`, so the apps it lists are Tessera's demos. + +With mise's shell completions enabled, `mise run dev:launch ` offers the demo names. Completion never builds: `scripts/dev-app-names.sh` asks the `Examples/.build/debug/tessera` that an earlier `dev:*` run built, and offers nothing before there is one. Run `mise run dev:apps` once after a fresh checkout, or after registering a demo. To complete `tessera` itself in your shell, see the `tessera` skill. + +## Run a demo + +`mise run dev:launch counter` opens the counter in your terminal. Each demo quits on `q`, matched by `isQuitKey` in `Examples/Sources/DemoSupport`. An agent without a terminal uses the pty recipe from the `tessera` skill with `mise run dev:launch counter` in place of `swift run tessera launch counter`; a headless snapshot test is the cheaper way to read a screen. + +## Snapshot-test a demo + +A demo's snapshot test is a few lines in `Examples/Tests/DemoSupportTests`, run by `swift test --package-path Examples`: + +```swift +@Test @MainActor +func `the counter counts`() async throws { + try await Demos.registry().withHeadlessHost("counter", at: .mobile) { host in + try await host.send(.key(Key(code: .character("+")))) + #expect(host.screenRows[2].hasSuffix("1")) + } +} +``` + +`withHeadlessHost` opens a terminal session over a headless device, steps the demo through the same driver as `launch`, and feeds every frame to one Ghostty virtual terminal, so `host.screenRows` is the screen a terminal would show. `@testable import TesseraHarness` reaches it; it is not API for app authors. The viewport names are the ones the `tessera` skill lists. + +## Add a demo + +1. Write a `final class` conforming to `Demo` in `Examples/Sources/DemoSupport`: a kebab-case `name`, a `minimum` size, `viewports`, and `fixtures` (empty when there are none). None has a default. +2. Handle the quit key in `apply(_:)` with `isQuitKey`, and report it through `isFinished`. +3. Register it with one line in `Demos.registry()`. +4. Add its snapshot test beside the counter's. + +A demo is interactive and shows every style or variant that can differ. It uses only what the library provides. + +## Rules + +- When a demo shows a library defect, fix the library and add a regression test. Never bend the demo to hide it. +- A name registered twice fails every command that reads the registry. Never rely on the later registration winning. diff --git a/.agents/skills/tessera/SKILL.md b/.agents/skills/tessera/SKILL.md new file mode 100644 index 00000000..574e1207 --- /dev/null +++ b/.agents/skills/tessera/SKILL.md @@ -0,0 +1,124 @@ +--- +name: tessera +description: Drive and check an app built on Tessera with the `tessera` command line. Use when writing a Tessera `Application`, registering it in an `ApplicationRegistry`, wiring the executable that calls `HarnessCLI.main(registry:)`, listing or launching the app with `tessera apps` and `tessera launch`, or reading what the harness reports about it. +version: 0.1.0 +--- + +# Tessera for app authors + +`TesseraHarness` runs a Tessera app live in a terminal and headlessly through one driver, so what a check renders is what a terminal shows. The `tessera` command line is how you and an agent reach it: you register your apps, wire one executable, and run commands against it. Use only the commands listed here. + +## Wire the executable + +1. Conform your app to `Application` where the type is declared: a `final class` with a `body`, an `apply(_:)` that receives each input event the views left unhandled (and every resize), and an `isFinished` that tells the harness to stop. The harness has no quit key of its own; the app decides which key quits. +2. Register it in an `ApplicationRegistry` under a kebab-case name, with its minimum size and the viewports it is checked at. A viewport is `.desktop` (80x24), `.mobile` (40 columns at the height the app's content needs), `.mobileScreen` (40x16), or `.custom(TerminalSize)`. There is no default viewport list. An app whose content grows to any height it is offered has no natural height, so `.mobile` fails and names `.mobileScreen`. +3. Make an executable whose body is `HarnessCLI.main(registry:)`, and name its product `tessera` so every command below runs as `swift run tessera COMMAND`. + +```swift +// Package.swift +let package = Package( + name: "MyApplication", + platforms: [.macOS(.v26)], + products: [.executable(name: "tessera", targets: ["MyAppDev"])], + dependencies: [ + .package(url: "https://github.com/robfeldmann/tessera.git", branch: "main") + ], + targets: [ + .target(name: "MyApp", dependencies: [.product(name: "Tessera", package: "tessera")]), + .executableTarget( + name: "MyAppDev", + dependencies: ["MyApp", .product(name: "TesseraHarness", package: "tessera")] + ), + ] +) +``` + +```swift +// Sources/MyApp/Counter.swift +import Tessera + +public final class Counter: Application { + public private(set) var count = 0 + public private(set) var isFinished = false + + public init() {} + + public var body: some View { + VStack { + Text("Count: \(count)") + Text("+ up q quit") + } + } + + public func apply(_ event: InputEvent) { + guard case .key(let key) = event, key.kind == .press else { return } + switch key.code { + case .character("+"): count += 1 + case .character("q"): isFinished = true + default: break + } + } +} +``` + +```swift +// Sources/MyAppDev/main.swift +import MyApp +import Tessera +import TesseraHarness + +var registry = ApplicationRegistry() +registry.register( + "counter", + minimum: TerminalSize(columns: 20, rows: 3), + viewports: [.desktop, .mobile] +) { Counter() } +await HarnessCLI.main(registry: registry) +``` + +The factory closure runs on the main actor once per run, so every run starts from a fresh app. A further registration of the same app in another starting state is one more `register` call under another name. + +## Commands + +| tessera | Does | +| --- | --- | +| `tessera apps` | List the registered apps: name, minimum size, viewports. | +| `tessera apps --json` or `--pretty` | Force the JSON object or the text table. | +| `tessera launch NAME` | Run one app in this terminal until the app quits. | +| `tessera help [COMMAND]` | Print the commands, or one command's usage. `tessera COMMAND --help` prints the same. | +| `tessera --generate-completion-script SHELL` | Print a completion script for `fish`, `zsh`, or `bash`. | + +Standard output is one JSON object when it is not a terminal, and a text table when it is. `apps` prints `{"apps": [{"minimum", "name", "viewports"}], "command": "apps"}`, sorted by name, where `minimum` is `null` for an app that declares none. A failed command prints `{"command", "exitCode", "message"}` instead. + +Exit codes: 0 success, 2 a usage error (an unknown command, app name, or flag, a missing or extra argument, or `--json` with `--pretty`; the message lists the accepted commands or app names, or the command's usage), 3 a harness error (a name registered twice, or `launch` without a terminal). `--json` and `--pretty` go after the command or before it. + +## Complete commands and app names + +The executable prints a completion script that completes the commands, their flags, and the registered app names, so `tessera launch ` offers your apps. Install it once per shell, with `tessera` on your `PATH` (the script runs it to ask for the names): + +```sh +# fish +tessera --generate-completion-script fish > ~/.config/fish/completions/tessera.fish +# zsh: a directory on $fpath, then restart the shell +tessera --generate-completion-script zsh > ~/.zfunc/_tessera +# bash: with bash-completion's per-command directory +tessera --generate-completion-script bash > ~/.local/share/bash-completion/completions/tessera +``` + +The script names the executable as it was invoked, so a product that is not called `tessera` generates a script for its own name. The names come from the executable you built, so rebuild after registering an app. + +## Run the app + +`swift run tessera launch counter` opens the app in your terminal. It needs a real terminal on standard input and output. An agent without one runs it under a pty, after building so compiler output stays out of the capture: + +```sh +swift build --product tessera +expect -c 'set timeout 20; spawn sh -c "stty rows 24 cols 80; exec swift run tessera launch counter"; expect "Count"; send "+"; send "q"; expect eof' +``` + +Read the screen from the bytes the run wrote: cursor addressing, then text. + +## Rules + +- When Tessera itself misbehaves (a crash, a wrong render, an input it drops), report a Tessera bug with the command, its output, and the smallest app that shows it. Do not work around it in the app. +- A name registered twice fails every command that reads the registry. Never rely on the later registration winning. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9eddfe42..5b9ebd90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -222,6 +222,12 @@ jobs: if: needs.ci-gate.outputs.vouch-only != 'true' run: swift test --only-use-versions-from-resolved-file --no-parallel + - name: Test Examples + if: needs.ci-gate.outputs.vouch-only != 'true' + run: + swift test --only-use-versions-from-resolved-file --no-parallel --package-path + Examples + - name: Check the resolved files are unchanged if: needs.ci-gate.outputs.vouch-only != 'true' run: git diff --exit-code -- Package.resolved Examples/Package.resolved diff --git a/AGENTS.md b/AGENTS.md index a8d7e60e..2c76590e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ - Ratatui crossterm backend: `~/Developer/ratatui/ratatui/main/ratatui-crossterm/` - Crossterm sources: `~/.local/share/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/crossterm-0.29.0/` - Project memory is the IWE workspace in `docs/` (`docs/index.md` maps it): `plans/` (one plan per piece of work; `docs/plans.md` is rendered from their frontmatter by `scripts/plans.py`), `research/` (research records), `architecture/` (durable decisions), `design/` (the component catalog), `spec/` (the spec, one page per section, hub `docs/spec.md`), and `guides/` (contributor how-tos). Search it at the start of a task and whenever unsure, from `docs/`: `iwe find --fuzzy --lexical `, then `iwe retrieve -k ` (`iwe` comes from `mise install`; run it as `mise exec -- iwe` when mise is not activated, and without it fall back to `docs/index.md`, the hub pages, and `grep` over `docs/`, which hold the same content as plain Markdown). Record what you learn where it belongs as part of the change (a plan's Progress, Decisions, or Discoveries; a research record; an architecture page), and fix stale entries you find. -- Plans and research records follow the `planning` and `investigations` skills in `.agents/skills/`; the design catalog follows `design-catalog`. Slash commands for the recurring workflows live in `.agents/commands/`. +- Plans and research records follow the `planning` and `investigations` skills in `.agents/skills/`; the design catalog follows `design-catalog`; driving and checking a Tessera app with the `tessera` command line follows the `tessera` skill, and running, launching, and snapshot-testing Tessera's own demos follows `tessera-contributing`, which extends it. Slash commands for the recurring workflows live in `.agents/commands/`. - Markdown prose is one paragraph per line (no hard wrapping); relative `.md` links; no raw HTML. Swift code keeps the formatter's 90-column width. - Swift Testing tests should use backticked, sentence-style function names. - Prefer snapshots for structured state humans inspect as a whole; prefer direct assertions for small scalar/API-shape behavior. diff --git a/CHANGELOG.md b/CHANGELOG.md index c0a78add..116d0e9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added runtime protocol controls and capability reconciliation with bounded active probes, serialized failure-safe mode transactions, live policy changes, emergency cleanup storage, repaint behavior, and a runtime-control demo. - Added `package`-level seams the headless harness drives, with no new public API: `InMemoryTerminalDevice` can stay open and deliver `.resize` mid-session (`init(…keepsInputOpen:)`, `deliverResize(to:)`, `closeInput()`); `TerminalSession.withApplicationTerminal(configuration:io:…)` forwards `probeImageID` and `probeSleep`; `TerminalSession` exposes `lastDrawn`, `size`, and `outputTap`; and `ViewGraph` reads an injected clock. - Added the `TesseraRuntime` module with the `Application` protocol, re-exported by `Tessera`: a reference type that owns its model, whose `body` is the root view, whose `apply(_:)` receives each event the view graph left unhandled and every resize, and whose `isFinished` (default `false`) tells a host to stop. It is class-only, inheriting from `AnyObject` and no other protocol, so plain classes, `@Observable` models, default-main-actor types, and `@MainActor` models (through `: @MainActor Application`) all adopt it where the type is declared. -- Added the `TesseraHarness` library product, the first home of the developer tooling: the scenario parser, runner, and report that the HeadlessRunner spike proved, ported with its scenario corpus and tests as `package` code. It declares no public API. +- Added the `TesseraHarness` library product, the first home of the developer tooling: the scenario parser, runner, and report that the HeadlessRunner spike proved, ported with its scenario corpus and tests as `package` code. +- Added the application registry, the viewports, and the main-actor driver to `TesseraHarness`, and the `tessera` command line over them. `ApplicationRegistry` selects an app by name through a main-actor factory that the harness opens once per run; registering a name twice makes every read of the registry fail rather than letting the later registration win. `Viewport` names `desktop` (80x24), `mobile` (40 columns at the app's natural height), `mobileScreen` (40x16), and `custom`; an app whose root grows with any height it is offered has no natural height, so running it at `mobile` fails and names `mobileScreen`. A `package` `Step.run(_:graph:event:isolation:)` in `TesseraRuntime` is the one loop body every host runs, and the `package` `Driver` is its main-actor shell, shared by a headless host (a terminal session over a headless device into a Ghostty virtual terminal) and a live host (a session over the real terminal), so a screen a test reads is the screen a terminal shows. `HarnessCLI.main(registry:)` runs `apps`, `launch NAME`, and `help [COMMAND]`. +- Added the `tessera` executable (`Examples/Sources/TesseraDev`) with `mise run dev:apps` and `mise run dev:launch NAME`, and the `DemoSupport` library with its first demo, a counter that quits on `q`; its snapshot tests step it through the same driver as `tessera launch`. +- Added the `tessera` agent skill for app authors (writing an `Application`, registering it, wiring the executable over `HarnessCLI.main(registry:)`, and `tessera apps` and `tessera launch`) and the `tessera-contributing` skill for contributors (the `mise run dev:*` tasks, the demos, and their snapshot tests), with a pointer from `AGENTS.md`. +- Added swift-argument-parser (`from: "1.8.2"`) as a dependency of `TesseraHarness` alone, and declared the `tessera` commands and flags with it. Exit codes (a usage error exits 2, not ArgumentParser's 64), the JSON object and the text form, and the command and flag names are unchanged; help and parse-error wording are ArgumentParser's, and a usage error still lists the accepted commands, app names, or the command's usage. The executable prints a completion script for fish, zsh, and bash (`--generate-completion-script SHELL`) that completes the registered app names for `launch`. `mise run dev:launch` with no name prints the command's help and the registered apps instead of mise's missing-argument error (exit 0 at a terminal, 2 in a pipe or a script), and `mise run dev:launch ` completes the app names from the built `tessera` without building. A `dev:*` task builds `tessera` once, quietly, and runs the binary it built. +- Added two compile-fail fixtures that pin the isolation rules of `Application`, each with a positive control, to `mise run quality:architecture`: an actor host given an app with a main-actor isolated conformance, and a default-main-actor type adopted from another module without `@MainActor` on the conformance. - Added Windows Ghostty snapshot support with a static library, reproducible Zig build and cache tooling, Frost VM recipes, console mode setup and restoration, live terminal device I/O, focused cross-platform test recipes, and hosted CI coverage. - Added public issue, Discussion, and pull-request forms, private security and conduct reporting, solo-maintainer ownership metadata, and a fork-safe reviewed contributor vouch gate with tested trust parsing. - Added read-only DocC pull-request validation plus main-branch and manual GitHub Pages deployment for the combined static documentation site. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c5b70264..f09b4f17 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,6 +101,8 @@ mise run core:build # build the package mise run core:test # full Swift test suite (non-parallel, as CI runs it) mise run core:test-parallel # the same suite in parallel; must stay green mise run core:example # run an example from the Examples package +mise run dev:apps # list the demos the tessera CLI can launch +mise run dev:launch counter # run one demo in this terminal (q quits) ``` Run the narrowest relevant check first, then widen: diff --git a/Examples/Package.resolved b/Examples/Package.resolved index cf294dae..42c5aff9 100644 --- a/Examples/Package.resolved +++ b/Examples/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "554cc87fb846fcc5c6be97d1a3e074152757c8d8338a1c255e40b01ab6e0076e", + "originHash" : "fef69be5dbd8efb2966dada1fefb525a5413ce5439b8fdd04e627a0b9c92064c", "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, { "identity" : "swift-custom-dump", "kind" : "remoteSourceControl", diff --git a/Examples/Package.swift b/Examples/Package.swift index c2e1ae5a..8945eb4e 100644 --- a/Examples/Package.swift +++ b/Examples/Package.swift @@ -19,6 +19,44 @@ let package = Package( // MARK: - ‡️ Dependencies +// MARK: CustomDump + +package.dependencies.append( + .package( + url: "https://github.com/pointfreeco/swift-custom-dump", + from: "1.0.0" + ) +) + +let CustomDump: Target.Dependency = .product( + name: "CustomDump", + package: "swift-custom-dump" +) + +// MARK: SnapshotTesting + +package.dependencies.append( + .package( + url: "https://github.com/pointfreeco/swift-snapshot-testing", + exact: "1.19.6" + ) +) + +let InlineSnapshotTesting: Target.Dependency = .product( + name: "InlineSnapshotTesting", + package: "swift-snapshot-testing" +) + +let SnapshotTesting: Target.Dependency = .product( + name: "SnapshotTesting", + package: "swift-snapshot-testing" +) + +let SnapshotTestingCustomDump: Target.Dependency = .product( + name: "SnapshotTestingCustomDump", + package: "swift-snapshot-testing" +) + // MARK: Tessera package.dependencies.append( @@ -30,6 +68,11 @@ let Tessera: Target.Dependency = .product( package: "tessera" ) +let TesseraHarness: Target.Dependency = .product( + name: "TesseraHarness", + package: "tessera" +) + let TesseraTerminal: Target.Dependency = .product( name: "TesseraTerminal", package: "tessera" @@ -38,7 +81,7 @@ let TesseraTerminal: Target.Dependency = .product( // MARK: - πŸš› Forward Module Declarations let ANSIEncoderDemo: Target.Dependency = .byName(name: "ANSIEncoderDemo") -let ApplicationConsumer: Target.Dependency = .byName(name: "ApplicationConsumer") +let DemoSupport: Target.Dependency = .byName(name: "DemoSupport") let ExampleSupport: Target.Dependency = .byName(name: "ExampleSupport") let HelloTessera: Target.Dependency = .byName(name: "HelloTessera") let InputInspector: Target.Dependency = .byName(name: "InputInspector") @@ -48,10 +91,11 @@ let Phase3ProtocolsDemoSupport: Target.Dependency = .byName( name: "Phase3ProtocolsDemoSupport" ) let RendererDemo: Target.Dependency = .byName(name: "RendererDemo") +let TesseraDev: Target.Dependency = .byName(name: "TesseraDev") let AllTesseraExampleTargetNames: Set = [ "ANSIEncoderDemo", - "ApplicationConsumer", + "DemoSupport", "ExampleSupport", "HelloTessera", "InputInspector", @@ -59,6 +103,7 @@ let AllTesseraExampleTargetNames: Set = [ "Phase3ProtocolsDemo", "Phase3ProtocolsDemoSupport", "RendererDemo", + "TesseraDev", ] // MARK: - 🎯 Products & Targets @@ -77,16 +122,29 @@ package.targets.append( ) ) -// MARK: ApplicationConsumer +// MARK: DemoSupport -package.targets.append( +package.targets.append(contentsOf: [ .target( - name: "ApplicationConsumer", + name: "DemoSupport", dependencies: [ - Tessera + Tessera, + TesseraHarness, ] - ) -) + ), + .testTarget( + name: "DemoSupportTests", + dependencies: [ + CustomDump, + DemoSupport, + InlineSnapshotTesting, + SnapshotTesting, + SnapshotTestingCustomDump, + Tessera, + TesseraHarness, + ] + ), +]) // MARK: ExampleSupport @@ -190,6 +248,20 @@ package.targets.append( ) ) +// MARK: TesseraDev + +package.products.append(.executable(name: "tessera", targets: ["TesseraDev"])) + +package.targets.append( + .executableTarget( + name: "TesseraDev", + dependencies: [ + DemoSupport, + TesseraHarness, + ] + ) +) + // MARK: - βš™οΈ Shared Swift Settings for target in package.targets { diff --git a/Examples/Sources/ApplicationConsumer/ApplicationConsumer.swift b/Examples/Sources/ApplicationConsumer/ApplicationConsumer.swift deleted file mode 100644 index 47e7195e..00000000 --- a/Examples/Sources/ApplicationConsumer/ApplicationConsumer.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Tessera - -// This target is compiled from outside the root package and imports only `Tessera`, so the -// Examples build fails if `Tessera` stops re-exporting `TesseraRuntime`: the adoption tests -// in the root package cannot see that. - -/// A nonisolated application: it runs on any host. -final class CounterApplication: Application { - private(set) var count = 0 - - var body: some View { - Text("Count: \(count)") - } - - func apply(_ event: InputEvent) { - if case .key(let key) = event, key.code == .character("+") { - count += 1 - } - } -} - -/// A main-actor application with an isolated conformance: only a main-actor host can use it. -@MainActor -final class MainActorCounterApplication: @MainActor Application { - private(set) var count = 0 - - var body: some View { - Text("Count: \(count)") - } - - func apply(_ event: InputEvent) { - if case .key(let key) = event, key.code == .character("+") { - count += 1 - } - } -} diff --git a/Examples/Sources/DemoSupport/Counter.swift b/Examples/Sources/DemoSupport/Counter.swift new file mode 100644 index 00000000..a6ec01b6 --- /dev/null +++ b/Examples/Sources/DemoSupport/Counter.swift @@ -0,0 +1,63 @@ +import Tessera +import TesseraHarness + +/// A counter: the smallest demo that takes input, changes state, and quits. +/// +/// `+` and `-` step the count, `0` resets it, and `q` quits. +final class Counter: Demo { + static let name = "counter" + static let minimum = TerminalSize(columns: 32, rows: 5) + static let viewports: [Viewport] = [.desktop, .mobile] + static let fixtures = [ + DemoFixture("counter-high") { Counter(count: 12_345) } + ] + + private(set) var count: Int + private var quitRequested = false + + var isFinished: Bool { + quitRequested + } + + var body: some View { + VStack(spacing: 0) { + Text("Counter") + Divider() + HStack(spacing: 0) { + Text("Count") + Spacer() + Text("\(count)") + } + Divider() + Text("+ up - down 0 reset q quit") + } + } + + convenience init() { + self.init(count: 0) + } + + private init(count: Int) { + self.count = count + } + + func apply(_ event: InputEvent) { + guard case .key(let key) = event else { + return + } + if isQuitKey(key) { + quitRequested = true + return + } + guard key.kind != .release, key.modifiers.isDisjoint(with: [.control, .alt, .meta]) + else { + return + } + switch key.code { + case .character("+"): count += 1 + case .character("-"): count -= 1 + case .character("0"): count = 0 + default: break + } + } +} diff --git a/Examples/Sources/DemoSupport/Demo.swift b/Examples/Sources/DemoSupport/Demo.swift new file mode 100644 index 00000000..18819001 --- /dev/null +++ b/Examples/Sources/DemoSupport/Demo.swift @@ -0,0 +1,50 @@ +import Tessera +import TesseraHarness + +/// One of Tessera's own per-component example apps. +/// +/// A demo is an ``Application`` that also declares how the harness finds and checks it. Every +/// declaration is required: there is no default name, size, viewport list, or fixture list, so +/// a demo states each of them where it is written. +/// +/// A demo handles its own quit key in ``Application/apply(_:)``, matching it with +/// ``isQuitKey(_:)``, and reports that through ``Application/isFinished``; the harness has no +/// quit key. A demo shows its component working: it is interactive, it shows every style and +/// variant that can differ, and it uses only what the library provides. When a demo exposes a +/// library defect, the library gets the fix and the demo gets no workaround. +public protocol Demo: Application { + /// The registry name: kebab-case, unique across every registration. + static var name: String { get } + + /// The smallest size the demo is designed for. + static var minimum: TerminalSize { get } + + /// The viewports the demo is checked at. + static var viewports: [Viewport] { get } + + /// Further registrations of the same demo, each starting from a different state. + static var fixtures: [DemoFixture] { get } + + /// Creates the demo in its initial state. + init() +} + +/// A further registration of a demo that starts from a state other than its initial one. +/// +/// A fixture takes the demo's minimum size and viewports. Its name is a registry name like +/// any other, so a scenario selects it with `app NAME`. +public struct DemoFixture: Sendable { + /// The registry name of the fixture. + public let name: String + + let make: @MainActor () -> D + + /// Creates a fixture registered as `name`. + /// + /// - Parameter make: Builds the demo in the fixture's state. It runs on the main actor, + /// once per run. + public init(_ name: String, make: @escaping @MainActor () -> D) { + self.name = name + self.make = make + } +} diff --git a/Examples/Sources/DemoSupport/Demos.swift b/Examples/Sources/DemoSupport/Demos.swift new file mode 100644 index 00000000..dac4ed2d --- /dev/null +++ b/Examples/Sources/DemoSupport/Demos.swift @@ -0,0 +1,27 @@ +import TesseraHarness + +/// Tessera's own demos, registered into one ``ApplicationRegistry``. +public enum Demos { + /// A registry holding every demo and every fixture of every demo. + /// + /// Add a demo by adding one line here. The `tessera` executable, the headless snapshot + /// tests, and the scenario corpus all select demos from this registry, so a demo that is not + /// registered here cannot be launched or checked. + @MainActor + public static func registry() -> ApplicationRegistry { + var registry = ApplicationRegistry() + register(Counter.self, into: ®istry) + return registry + } + + @MainActor + private static func register( + _ demo: D.Type, into registry: inout ApplicationRegistry + ) { + registry.register(D.name, minimum: D.minimum, viewports: D.viewports) { D() } + for fixture in D.fixtures { + registry.register( + fixture.name, minimum: D.minimum, viewports: D.viewports, make: fixture.make) + } + } +} diff --git a/Examples/Sources/DemoSupport/QuitKey.swift b/Examples/Sources/DemoSupport/QuitKey.swift new file mode 100644 index 00000000..d98d6491 --- /dev/null +++ b/Examples/Sources/DemoSupport/QuitKey.swift @@ -0,0 +1,11 @@ +import Tessera + +/// Whether `key` is the key every demo quits on: an unmodified press of `q`. +/// +/// The match is on the key's code, modifiers, and kind, never on `Key` equality, because a +/// terminal that reports enriched keys (the Kitty keyboard protocol) fills in shifted and +/// base-layout codes and associated text, and none of those make it a different key. A +/// release or a repeat does not quit. +public func isQuitKey(_ key: Key) -> Bool { + key.kind == .press && key.modifiers.isEmpty && key.code == .character("q") +} diff --git a/Examples/Sources/TesseraDev/main.swift b/Examples/Sources/TesseraDev/main.swift new file mode 100644 index 00000000..defda1d5 --- /dev/null +++ b/Examples/Sources/TesseraDev/main.swift @@ -0,0 +1,6 @@ +import DemoSupport +import TesseraHarness + +// `tessera`: Tessera's own executable. It registers Tessera's demos and hands the command +// line to the harness; an app author's executable does the same over the app's own registry. +await HarnessCLI.main(registry: Demos.registry()) diff --git a/Examples/Tests/DemoSupportTests/CounterDemoTests.swift b/Examples/Tests/DemoSupportTests/CounterDemoTests.swift new file mode 100644 index 00000000..92bc6052 --- /dev/null +++ b/Examples/Tests/DemoSupportTests/CounterDemoTests.swift @@ -0,0 +1,126 @@ +import CustomDump +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump +import Tessera +import Testing + +@testable import DemoSupport +@testable import TesseraHarness + +@MainActor +struct CounterDemoTests { + private let plus = InputEvent.key(Key(code: .character("+"))) + + @Test + func `the counter renders at desktop`() async throws { + try await Demos.registry().withHeadlessHost("counter", at: .desktop) { host in + #expect(host.size == TerminalSize(columns: 80, rows: 24)) + assertInlineSnapshot(of: host.screenRows.joined(separator: "\n"), as: .lines) { + """ + Counter + ──────────────────────────────────────────────────────────────────────────────── + Count 0 + ──────────────────────────────────────────────────────────────────────────────── + + up - down 0 reset q quit + + + + + + + + + + + + + + + + + + + + """ + } + } + } + + @Test + func `the counter renders at mobile in the rows its content needs`() async throws { + try await Demos.registry().withHeadlessHost("counter", at: .mobile) { host in + #expect(host.size == TerminalSize(columns: 40, rows: 5)) + assertInlineSnapshot(of: host.screenRows.joined(separator: "\n"), as: .lines) { + """ + Counter + ──────────────────────────────────────── + Count 0 + ──────────────────────────────────────── + + up - down 0 reset q quit + """ + } + } + } + + @Test + func `plus and minus step the count and zero resets it`() async throws { + try await Demos.registry().withHeadlessHost("counter", at: .mobile) { host in + var counts: [String] = [] + try await host.send(plus) + try await host.send(plus) + counts.append(host.screenRows[2]) + try await host.send(.key(Key(code: .character("-")))) + counts.append(host.screenRows[2]) + try await host.send(.key(Key(code: .character("0")))) + counts.append(host.screenRows[2]) + assertInlineSnapshot(of: counts, as: .customDump) { + """ + [ + [0]: "Count 2", + [1]: "Count 1", + [2]: "Count 0" + ] + """ + } + } + } + + @Test + func `q finishes the demo and other keys do not`() async throws { + try await Demos.registry().withHeadlessHost("counter", at: .desktop) { host in + try await host.send(.key(Key(code: .character("x")))) + try await host.send(.key(Key(code: .character("q"), modifiers: .control))) + #expect(host.isFinished == false) + try await host.send(.key(Key(code: .character("q")))) + #expect(host.isFinished) + } + } + + @Test + func `the fixture starts from its own state`() async throws { + try await Demos.registry().withHeadlessHost("counter-high", at: .desktop) { host in + #expect(host.screenRows[2].hasSuffix("12345")) + } + } + + @Test + func `every registered demo renders at every viewport it declares`() async throws { + let registry = Demos.registry() + for entry in try registry.checkedEntries() { + for viewport in entry.viewports { + try await registry.withHeadlessHost(entry.name, at: viewport) { host in + let drawn = host.screenRows.contains { !$0.isEmpty } + #expect(drawn, "\(entry.name) at \(viewport.name)") + } + } + } + } + + @Test + func `the demo registry has no duplicate names`() throws { + let names = try Demos.registry().names() + + expectNoDifference(names, ["counter", "counter-high"]) + } +} diff --git a/Examples/Tests/DemoSupportTests/QuitKeyTests.swift b/Examples/Tests/DemoSupportTests/QuitKeyTests.swift new file mode 100644 index 00000000..fedf1081 --- /dev/null +++ b/Examples/Tests/DemoSupportTests/QuitKeyTests.swift @@ -0,0 +1,34 @@ +import Tessera +import Testing + +@testable import DemoSupport + +struct QuitKeyTests { + @Test + func `an unmodified press of q quits`() { + #expect(isQuitKey(Key(code: .character("q")))) + } + + @Test + func `an enriched q still quits because the match ignores alternate codes and text`() { + let enriched = Key( + code: .character("q"), + shiftedCode: .character("Q"), + baseLayoutCode: .character("q"), + associatedText: "q" + ) + + #expect(enriched != Key(code: .character("q"))) + #expect(isQuitKey(enriched)) + } + + @Test + func `a modified, repeated, released, or different key does not quit`() { + #expect(isQuitKey(Key(code: .character("q"), modifiers: .control)) == false) + #expect(isQuitKey(Key(code: .character("q"), modifiers: .alt)) == false) + #expect(isQuitKey(Key(code: .character("q"), kind: .repeat)) == false) + #expect(isQuitKey(Key(code: .character("q"), kind: .release)) == false) + #expect(isQuitKey(Key(code: .character("w"))) == false) + #expect(isQuitKey(Key(code: .escape)) == false) + } +} diff --git a/Package.resolved b/Package.resolved index 6e900f8b..ad1aa9b7 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "01ec85bad3105b18e5c21e589632e7f15422968859f2a18054a56c0f940cb0f5", + "originHash" : "c656a39916f8c137260235e973dddf9c99c5c1bf2ee112da99e5224e60a33ee5", "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, { "identity" : "swift-custom-dump", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 0abc6853..e5608c9c 100644 --- a/Package.swift +++ b/Package.swift @@ -20,6 +20,20 @@ let package = Package( // MARK: - ‡️ Dependencies +// MARK: ArgumentParser + +package.dependencies.append( + .package( + url: "https://github.com/apple/swift-argument-parser", + from: "1.8.2" + ) +) + +let ArgumentParser: Target.Dependency = .product( + name: "ArgumentParser", + package: "swift-argument-parser" +) + // MARK: CustomDump package.dependencies.append( @@ -270,13 +284,14 @@ package.targets.append(contentsOf: [ .target( name: "TesseraHarness", dependencies: [ + ArgumentParser, TesseraCore, - TesseraLayout, TesseraRuntime, + TesseraTerminal, TesseraTerminalCore, TesseraTerminalInput, + TesseraTerminalIO, TesseraTerminalSnapshotSupport, - TesseraWidgets, ] ), .testTarget( @@ -286,8 +301,16 @@ package.targets.append(contentsOf: [ InlineSnapshotTesting, SnapshotTesting, SnapshotTestingCustomDump, + TesseraCore, TesseraHarness, + TesseraLayout, + TesseraRuntime, + TesseraTerminal, + TesseraTerminalCore, TesseraTerminalInput, + TesseraTerminalSnapshotSupport, + TesseraTerminalTestSupport, + TesseraWidgets, ], exclude: ["Scenarios"] ), diff --git a/README.md b/README.md index f016727e..354b4b99 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ The supported/tested OS-version range widens as the view layer matures. Contribu | `TesseraTerminalSnapshotSupport` | Ghostty-backed virtual-terminal support for output snapshot testing. | | `TesseraTerminalTestSupport` | Fixtures and utilities for terminal-behavior tests. | | `TesseraTestSupport` | General Tessera test and snapshot support. | -| `TesseraHarness` | Drive and check Tessera apps headlessly from test and development targets (pre-API). | +| `TesseraHarness` | Register, launch, and drive Tessera apps from test and development targets: `ApplicationRegistry`, `Viewport`, and `HarnessCLI` (the scenario runner is not public yet). | ## Roadmap diff --git a/Sources/TesseraCore/Runtime/ViewGraph.swift b/Sources/TesseraCore/Runtime/ViewGraph.swift index 6233a59d..2127ba29 100644 --- a/Sources/TesseraCore/Runtime/ViewGraph.swift +++ b/Sources/TesseraCore/Runtime/ViewGraph.swift @@ -164,6 +164,27 @@ extension ViewGraph { lines.append("requirements requested=\(requirements) effective=unavailable") return lines.joined(separator: "\n") } + + /// Measures the root under `proposal` without placing or drawing it. + /// + /// A layout pass always proposes the whole viewport, so no pass can ask a view for its + /// natural extent. A `nil` dimension in `proposal` does: a stack answers with its content, + /// and a view that fills whatever it is offered (a scrolling viewport, a spacer) answers + /// with its content or its minimum instead of the viewport. Proposing two different + /// heights and comparing the answers tells a view that fills its proposal from one that + /// does not. + /// + /// The measurement leaves the statistics as they were and invalidates layout, so the next + /// pass measures every node again from scratch. + package func measuredRootSize(proposing proposal: ProposedSize) -> TerminalSize { + let recorded = statistics + layoutMeasurements.removeAll(keepingCapacity: true) + let measured = measure(rootNode, proposal: proposal) + layoutMeasurements.removeAll(keepingCapacity: true) + statistics = recorded + markSubtreeNeedsLayout(rootNode) + return measured + } } extension ViewGraph { diff --git a/Sources/TesseraHarness/ActorHost.swift b/Sources/TesseraHarness/ActorHost.swift new file mode 100644 index 00000000..0eaa0767 --- /dev/null +++ b/Sources/TesseraHarness/ActorHost.swift @@ -0,0 +1,52 @@ +import TesseraCore +import TesseraRuntime +import TesseraTerminalCore +import TesseraTerminalInput +import TesseraTerminalSnapshotSupport + +/// Hosts one application on an actor of its own, so suites that step applications run in +/// parallel instead of queueing on the main actor. +/// +/// The actor passes itself as the isolation of the same ``Step`` the main-actor ``Driver`` +/// uses. A nonisolated application runs here unchanged; an application with a main-actor +/// isolated conformance is rejected at the call site that names it, because only a +/// main-actor host can use that conformance. +/// +/// This host draws nothing through a terminal session. It renders the graph into a fresh +/// virtual terminal when asked for the screen. +actor ActorHost { + private let app: App + private let graph: ViewGraph + private var size: TerminalSize + + /// Builds the application on this actor and tells it its starting size. + init(size: TerminalSize, make: sending () -> App) async { + let app = make() + self.app = app + self.size = size + graph = ViewGraph(root: { app.body }, size: size) + Step.run(app, graph: graph, event: .resize(size), isolation: self) + } + + /// Delivers one event and returns how the graph disposed of it. + @discardableResult + func step(_ event: InputEvent) -> EventDisposition { + if case .resize(let newSize) = event { + size = newSize + } + return Step.run(app, graph: graph, event: event, isolation: self) + } + + /// The visible screen, one string per row, with trailing blanks trimmed. + func screenRows() -> [String] { + let graph = graph + return VirtualTerminal.snapshot(size: size) { frame in + graph.render(into: frame) + }.trimmedRows + } + + /// Reads the application's state on this actor. + func read(_ body: sending (App) -> Result) -> Result { + body(app) + } +} diff --git a/Sources/TesseraHarness/ApplicationRegistry.swift b/Sources/TesseraHarness/ApplicationRegistry.swift new file mode 100644 index 00000000..b2aaf937 --- /dev/null +++ b/Sources/TesseraHarness/ApplicationRegistry.swift @@ -0,0 +1,136 @@ +import TesseraRuntime +import TesseraTerminal +import TesseraTerminalCore + +/// The one place applications are selected by name. +/// +/// A registry maps a name to a factory. The harness opens a factory on the main actor, once +/// per run, so every run starts from a fresh application, and a fixture is one more +/// registration of the same type: +/// +/// ```swift +/// var registry = ApplicationRegistry() +/// registry.register("browser", viewports: [.desktop, .mobileScreen]) { Browser() } +/// registry.register("browser-empty", viewports: [.desktop]) { Browser(files: .empty) } +/// ``` +/// +/// Registering a name twice is not an error at the registration site. It makes every entry +/// point that reads the registry fail instead, so a later registration never silently +/// replaces an earlier one. +public struct ApplicationRegistry { + /// One registered application, with its factory erased behind the two ways to run it. + package struct Entry { + package let name: String + package let minimum: TerminalSize? + package let viewports: [Viewport] + + let headless: + @MainActor ( + Viewport, @escaping @MainActor (any ScenarioHost) async throws -> Void + ) async throws -> Void + let live: @MainActor (TerminalApplicationConfiguration) async throws -> Void + } + + package private(set) var entries: [Entry] = [] + + /// Creates a registry with no applications. + public init() {} + + /// Registers an application under `name`. + /// + /// - Parameters: + /// - name: The name scenarios and commands select the application by. + /// - minimum: The smallest size the application is designed for, if it declares one. + /// - viewports: The viewports the application is checked at. There is no default. + /// - make: Builds a fresh application. It runs on the main actor, so an application whose + /// model is main-actor isolated can be built here, and a `@Sendable` closure for such + /// an application does not compile. + public mutating func register( + _ name: String, + minimum: TerminalSize? = nil, + viewports: [Viewport], + make: @escaping @MainActor () -> App + ) { + entries.append( + Entry( + name: name, + minimum: minimum, + viewports: viewports, + headless: { viewport, body in + try await HeadlessHost.run( + name: name, viewport: viewport, make: make, body: body) + }, + live: { configuration in + try await LiveHost.run(make: make, configuration: configuration) + } + ) + ) + } +} + +/// Why the registry could not answer a question. +package enum RegistryError: CustomStringConvertible, Equatable, Error, Sendable { + /// Names registered more than once, each listed once, in sorted order. + case duplicate([String]) + + /// A name nothing is registered under, with the names that are. + case unknown(String, accepted: [String]) + + package var description: String { + switch self { + case .duplicate(let names): + let quoted = names.map { "`\($0)`" }.joined(separator: ", ") + return "applications registered more than once: \(quoted)" + case .unknown(let name, let accepted): + let list = accepted.isEmpty ? "none registered" : accepted.joined(separator: ", ") + return "unknown app `\(name)`; accepted: \(list)" + } + } +} + +extension ApplicationRegistry { + /// Every entry, sorted by name. + /// + /// Every read of the registry goes through here, so a duplicate name fails all of them, + /// including a lookup of a name that is itself unique. + package func checkedEntries() throws(RegistryError) -> [Entry] { + var seen: Set = [] + var duplicates: Set = [] + for entry in entries where !seen.insert(entry.name).inserted { + duplicates.insert(entry.name) + } + guard duplicates.isEmpty else { + throw .duplicate(duplicates.sorted()) + } + return entries.sorted { $0.name < $1.name } + } + + /// The registered names, sorted. + package func names() throws(RegistryError) -> [String] { + try checkedEntries().map(\.name) + } + + /// The entry registered under `name`. + package func entry(named name: String) throws(RegistryError) -> Entry { + let entries = try checkedEntries() + guard let entry = entries.first(where: { $0.name == name }) else { + throw .unknown(name, accepted: entries.map(\.name)) + } + return entry + } +} + +extension ApplicationRegistry { + /// Runs `body` against the application registered as `name`, headless at `viewport`. + /// + /// The factory runs once, on the main actor, and the session closes when `body` returns or + /// throws. A snapshot test is a call to this, a few sends, and an assertion on the screen. + @MainActor + package func withHeadlessHost( + _ name: String, + at viewport: Viewport, + _ body: @escaping @MainActor (any ScenarioHost) async throws -> Void + ) async throws { + try await entry(named: name).headless(viewport, body) + } +} diff --git a/Sources/TesseraHarness/Driver.swift b/Sources/TesseraHarness/Driver.swift index 7509337a..0fb1fec0 100644 --- a/Sources/TesseraHarness/Driver.swift +++ b/Sources/TesseraHarness/Driver.swift @@ -1,79 +1,80 @@ import TesseraCore +import TesseraRuntime +import TesseraTerminal import TesseraTerminalCore import TesseraTerminalInput -import TesseraTerminalSnapshotSupport -/// An application the headless runner can drive. +/// Runs one application on the main actor, one input event at a time, and draws every +/// change through a terminal session. /// -/// `ViewGraph.dispatch` routes no input and returns `.ignored` for every event, so input -/// never reaches views. The app therefore owns its model: ``apply(_:)`` receives each -/// event after `graph.dispatch` returned `.ignored`, as an `Application` host does. -package protocol DemoApp: AnyObject { - associatedtype Root: View - - static var name: String { get } - - var root: Root { get } - - init() - - /// Applies one input event to app-owned state. - func apply(_ event: InputEvent) -} +/// A driver is a thin host over ``Step``: it owns the application, the ``ViewGraph`` that +/// lowers its body, and the session it presents through. The headless host and the live host +/// both drive an application through this type, so what a scenario renders is what a terminal +/// shows. The session is an actor that receives finished frames; the frame itself is drawn +/// here, in the main actor's isolation, because the graph lives here. +/// +/// The driver has no quit key and no focus policy. It stops being useful when the application +/// reports ``isFinished``, and the host decides what to do about that. +@MainActor +final class Driver { + /// The application under test. + let app: App -/// The type-erased surface the scenario runner uses. -protocol ScenarioHost: AnyObject { - var size: TerminalSize { get } - var screenRows: [String] { get } - var diagnostics: GraphDiagnostics { get } - func send(_ event: InputEvent) -} + /// The graph that lowers the application's body. + let graph: ViewGraph -/// Runs the canonical loop once per event: -/// `resize β†’ graph.dispatch β†’ app.apply β†’ graph.update β†’ Buffer β†’ Renderer β†’ Ghostty`. -final class Driver: ScenarioHost { - private let app: App - private let graph: ViewGraph + /// The size the graph was last laid out at. private(set) var size: TerminalSize - private var screen = ScreenSnapshot.empty - var diagnostics: GraphDiagnostics { - graph.diagnostics - } + private let session: TerminalSession - var screenRows: [String] { - screen.cells.map { row in - var text = String(row.map(\.character)) - while text.last == " " { - text.removeLast() - } - return text - } + /// Whether the application has asked its host to stop. + var isFinished: Bool { + app.isFinished } - init(size: TerminalSize) { - let app = App() - app.apply(.resize(size)) + /// Creates a driver whose graph starts at `size` and draws through `session`. + init( + app: App, + size: TerminalSize, + session: TerminalSession, + environment: EnvironmentValues = EnvironmentValues() + ) { self.app = app self.size = size - graph = ViewGraph(root: { app.root }, size: size) - present() + self.session = session + graph = ViewGraph(root: { app.body }, size: size, environment: environment) } - func send(_ event: InputEvent) { + /// Tells the application its starting size and draws the first frame. + /// + /// The size reaches the application as a resize event, through the same step as every + /// later event, because an application has no other way to learn the size it starts at. + func start() async throws { + Step.run(app, graph: graph, event: .resize(size), isolation: MainActor.shared) + try await present() + } + + /// Delivers one event, then draws a frame if anything changed. + /// + /// When this returns, the application has applied the event, the graph is updated, and the + /// frame is committed to the session. + @discardableResult + func step(_ event: InputEvent) async throws -> EventDisposition { if case .resize(let newSize) = event { size = newSize - graph.resize(to: newSize) } - _ = graph.dispatch(event) - app.apply(event) - graph.update() - present() + let disposition = Step.run( + app, graph: graph, event: event, isolation: MainActor.shared) + if graph.needsRender { + try await present() + } + return disposition } - private func present() { + private func present() async throws { let graph = graph - screen = VirtualTerminal.snapshot(size: size) { frame in + try await session.draw { frame in graph.render(into: frame) } } diff --git a/Sources/TesseraHarness/HarnessCLI+Commands.swift b/Sources/TesseraHarness/HarnessCLI+Commands.swift new file mode 100644 index 00000000..7b607092 --- /dev/null +++ b/Sources/TesseraHarness/HarnessCLI+Commands.swift @@ -0,0 +1,212 @@ +import ArgumentParser +import Foundation +import TesseraTerminal + +/// A command ArgumentParser parses and the harness then runs. +/// +/// The conforming type declares what the command accepts; ``execute(in:)`` is what it does. +/// It stays apart from ArgumentParser's `run()` so that a command returns what it printed +/// instead of printing it, which lets a test run one without a process. +protocol HarnessCommand: ParsableCommand { + /// Runs the command and returns what it printed and how it ended. + @MainActor + func execute(in context: HarnessCLI.Context) async -> CommandOutput +} + +extension HarnessCLI { + /// What a parsed command runs against. + struct Context { + var asJSON: Bool + var configuration: TerminalApplicationConfiguration + var registry: ApplicationRegistry + var terminals: (input: Bool, output: Bool) + } + + /// The command line before any command: it has no behavior of its own, so a command line + /// that parses to it named no command. + struct Root: ParsableCommand { + static var configuration: CommandConfiguration { + CommandConfiguration( + commandName: Invocation.current.executableName, + abstract: "List and launch the registered applications.", + discussion: """ + Every command exits 0 on success, 2 on a usage error, and 3 on a harness error. \ + When standard output is not a terminal each command writes one JSON object to it; \ + --json forces that form and --pretty forces the text form. + + `--generate-completion-script SHELL` prints a completion script for fish, zsh, or \ + bash. It completes the commands, their flags, and the registered application names. + """, + subcommands: HarnessCLI.commands.map { $0 as any ParsableCommand.Type }) + } + + @OptionGroup var output: OutputOptions + } +} + +/// The flags that choose between a command's JSON object and its text form. +/// +/// ArgumentParser accepts, documents, and completes them, and rejects the two together. The +/// form itself is read from the raw arguments by ``prefersJSON(_:outputIsTerminal:)``: a parse +/// that fails must still be reported in the form the caller asked for. +struct OutputOptions: ParsableArguments { + @Flag var form: OutputForm? +} + +enum OutputForm: String, EnumerableFlag { + case json, pretty + + /// The flag spellings, as typed. + static let spellings = allCases.map { "--\($0.rawValue)" } + + static func help(for value: Self) -> ArgumentHelp? { + switch value { + case .json: "Print one JSON object, even on a terminal." + case .pretty: "Print the text form, even when output is not a terminal." + } + } + + /// Whether `arguments` ask for the JSON object: `--json` does, `--pretty` does not, and + /// with neither it is the case exactly when standard output is not a terminal. Both flags + /// contradict each other and ask for text, so the error that says so can be read. + static func prefersJSON(_ arguments: [String], outputIsTerminal: Bool) -> Bool { + switch (arguments.contains("--json"), arguments.contains("--pretty")) { + case (true, false): true + case (false, true), (true, true): false + case (false, false): !outputIsTerminal + } + } +} + +struct AppsCommand: HarnessCommand { + /// `apps`' JSON object. + private struct Listing: Encodable { + /// One application. `minimum` is written as `null` when the app declares none, so every + /// entry has the same three keys. + struct App: Encodable { + enum CodingKeys: String, CodingKey { + case minimum, name, viewports + } + + var minimum: String? + var name: String + var viewports: [String] + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(minimum, forKey: .minimum) + try container.encode(name, forKey: .name) + try container.encode(viewports, forKey: .viewports) + } + } + + var apps: [App] + var command = "apps" + } + + static let configuration = CommandConfiguration( + commandName: "apps", + abstract: "List the registered applications.", + discussion: """ + Lists every registered application with the smallest size it declares and the \ + viewports it is checked at. The JSON form is {"apps": [{"minimum", "name", \ + "viewports"}], "command": "apps"}, sorted by name, where "minimum" is null for an \ + application that declares none. A name registered twice fails the command with exit 3. + """) + + @OptionGroup var output: OutputOptions + + func execute(in context: HarnessCLI.Context) -> CommandOutput { + let entries: [ApplicationRegistry.Entry] + do { + entries = try context.registry.checkedEntries() + } catch { + return HarnessCLI.failure( + error.description, exitCode: 3, command: "apps", json: context.asJSON) + } + + if context.asJSON { + let listing = Listing( + apps: entries.map { entry in + Listing.App( + minimum: entry.minimum.map { "\($0.columns)x\($0.rows)" }, + name: entry.name, + viewports: entry.viewports.map(\.name)) + }) + return CommandOutput(exitCode: 0, stdout: HarnessCLI.encode(listing) + "\n") + } + + guard !entries.isEmpty else { + return CommandOutput(exitCode: 0, stdout: "no applications are registered\n") + } + let nameWidth = max("NAME".count, entries.map(\.name.count).max() ?? 0) + let minimums = entries.map { $0.minimum.map { "\($0.columns)x\($0.rows)" } ?? "-" } + let minimumWidth = max("MINIMUM".count, minimums.map(\.count).max() ?? 0) + func row(_ name: String, _ minimum: String, _ viewports: String) -> String { + name.padding(toLength: nameWidth, withPad: " ", startingAt: 0) + " " + + minimum.padding(toLength: minimumWidth, withPad: " ", startingAt: 0) + " " + + viewports + } + var lines = [row("NAME", "MINIMUM", "VIEWPORTS")] + for (entry, minimum) in zip(entries, minimums) { + lines.append( + row(entry.name, minimum, entry.viewports.map(\.name).joined(separator: ", "))) + } + return CommandOutput(exitCode: 0, stdout: lines.joined(separator: "\n") + "\n") + } +} + +struct LaunchCommand: HarnessCommand { + static let configuration = CommandConfiguration( + commandName: "launch", + abstract: "Run one application in this terminal.", + discussion: """ + Runs the named application at the terminal's size, until the application finishes. \ + The harness has no quit key of its own: the application decides which key quits. + + Exits 2 for an unknown name, and 3 for a duplicate registration or when standard \ + input or output is not a terminal. + """) + + @OptionGroup var output: OutputOptions + + @Argument( + help: "The application to run, as `apps` lists it.", + completion: .custom { _, _, _ in HarnessCLI.Invocation.current.appNames }) + var name: String + + @MainActor + func execute(in context: HarnessCLI.Context) async -> CommandOutput { + let entry: ApplicationRegistry.Entry + do { + entry = try context.registry.entry(named: name) + } catch { + let exitCode: Int32 = + switch error { + case .duplicate: 3 + case .unknown: 2 + } + return HarnessCLI.failure( + error.description, exitCode: exitCode, command: "launch", json: context.asJSON) + } + guard context.terminals.input, context.terminals.output else { + let missing = + switch context.terminals { + case (false, false): "standard input and output are not terminals" + case (false, true): "standard input is not one" + default: "standard output is not one" + } + return HarnessCLI.failure( + "`launch` runs an application in this terminal, and \(missing)", + exitCode: 3, command: "launch", json: context.asJSON) + } + do { + try await entry.live(context.configuration) + } catch { + return HarnessCLI.failure( + "`launch \(name)` could not run in this terminal: \(error)", + exitCode: 3, command: "launch", json: context.asJSON) + } + return CommandOutput(exitCode: 0) + } +} diff --git a/Sources/TesseraHarness/HarnessCLI.swift b/Sources/TesseraHarness/HarnessCLI.swift new file mode 100644 index 00000000..c2d6f730 --- /dev/null +++ b/Sources/TesseraHarness/HarnessCLI.swift @@ -0,0 +1,242 @@ +import ArgumentParser +import Foundation +import TesseraTerminal + +/// The command line over an ``ApplicationRegistry``. +/// +/// A development executable registers its applications and calls ``main(registry:)``: +/// +/// ```swift +/// var registry = ApplicationRegistry() +/// registry.register("browser", viewports: [.desktop]) { Browser() } +/// await HarnessCLI.main(registry: registry) +/// ``` +/// +/// The commands are `apps` (list the registry), `launch NAME` (run one application in this +/// terminal), and `help [COMMAND]`. Exit codes are 0 for success, 2 for a usage error, and 3 +/// for a harness error. When standard output is not a terminal, each command writes one JSON +/// object to it; `--json` forces that form and `--pretty` forces the text form. +/// +/// The executable also prints a completion script for fish, zsh, and bash, which completes +/// the registered application names: `--generate-completion-script SHELL`. +public enum HarnessCLI { + /// Runs the command in the process's arguments and exits with its status. + /// + /// - Parameter registry: The applications the commands can list and launch. + @MainActor + public static func main(registry: ApplicationRegistry) async -> Never { + let output = await run( + arguments: Array(CommandLine.arguments.dropFirst()), + registry: registry, + inputIsTerminal: StandardStreams.inputIsTerminal, + outputIsTerminal: StandardStreams.outputIsTerminal, + configuration: .default, + executableName: ProcessInfo.processInfo.processName, + columns: nil + ) + if !output.stdout.isEmpty { + FileHandle.standardOutput.write(Data(output.stdout.utf8)) + } + if !output.stderr.isEmpty { + FileHandle.standardError.write(Data(output.stderr.utf8)) + } + exit(output.exitCode) + } +} + +/// What a command printed and how it ended. +struct CommandOutput: Equatable { + var exitCode: Int32 + var stdout = "" + var stderr = "" +} + +extension HarnessCLI { + /// What the commands read while ArgumentParser builds them and writes their help. + /// + /// ArgumentParser creates a command by calling its initializer, and reads its configuration + /// and completion closures with no argument of ours, so the executable's name and the + /// registered names reach them through a task-local value instead. + struct Invocation: Sendable { + @TaskLocal static var current = Self() + + var appNames: [String] = [] + var executableName = "tessera" + } + + /// A failed command. `command` is written as `null` when the failure precedes naming one. + private struct Failure: Encodable { + enum CodingKeys: String, CodingKey { + case command, exitCode, message + } + + var command: String? + var exitCode: Int32 + var message: String + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(command, forKey: .command) + try container.encode(exitCode, forKey: .exitCode) + try container.encode(message, forKey: .message) + } + } + + static let commands: [any HarnessCommand.Type] = [AppsCommand.self, LaunchCommand.self] + + /// The words that name a command: the commands, then ArgumentParser's `help`. + static var commandNames: [String] { + // A key path through an existential metatype crashes the Swift 6.3 compiler in SILGen. + // swiftlint:disable:next prefer_key_path + commands.map { $0._commandName } + ["help"] + } + + /// Runs one command and returns what it printed, without touching the process. + /// + /// `launch` needs a terminal on both ends, so it refuses to start unless `inputIsTerminal` + /// and `outputIsTerminal` are both `true`; this keeps a test or a pipe from ever opening a + /// live session, and fails before the terminal changes mode rather than after. + /// + /// - Parameters: + /// - executableName: What usage and completion scripts call the executable. + /// - columns: The width help text wraps at, or `nil` for the terminal's width. + @MainActor + static func run( + arguments: [String], + registry: ApplicationRegistry, + inputIsTerminal: Bool, + outputIsTerminal: Bool, + configuration: TerminalApplicationConfiguration, + executableName: String = "tessera", + columns: Int? = 80 + ) async -> CommandOutput { + let invocation = Invocation( + appNames: (try? registry.names()) ?? [], executableName: executableName) + return await Invocation.$current.withValue(invocation) { + // The form comes from the raw arguments, not the parsed command: a parse that fails + // must still be reported in the form the caller asked for. + let asJSON = OutputForm.prefersJSON(arguments, outputIsTerminal: outputIsTerminal) + + // ArgumentParser reports an unknown command word as an unexpected argument, and + // `help ghost` as the overview. Both are usage errors that list the commands here. + if let unknown = unknownCommand(in: arguments, json: asJSON) { + return unknown + } + + let parsed: any ParsableCommand + do { + parsed = try Root.parseAsRoot(arguments) + } catch { + return parseFailure(error, arguments: arguments, columns: columns, json: asJSON) + } + switch parsed { + case let command as any HarnessCommand: + return await command.execute( + in: Context( + asJSON: asJSON, + configuration: configuration, + registry: registry, + terminals: (input: inputIsTerminal, output: outputIsTerminal))) + case is Root: + return usage( + "a command is required; accepted: \(commandNames.joined(separator: ", "))", + command: nil, json: asJSON) + default: + // ArgumentParser's own `help`, which throws the help request it was given. + var help = parsed + do { + try help.run() + } catch { + return parseFailure(error, arguments: arguments, columns: columns, json: asJSON) + } + return CommandOutput(exitCode: 0) + } + } + } + + // MARK: Output + + static func encode(_ value: some Encodable) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let data = (try? encoder.encode(value)) ?? Data() + // swiftlint:disable:next optional_data_string_conversion + return String(decoding: data, as: UTF8.self) + } + + static func usage(_ message: String, command: String?, json: Bool) -> CommandOutput { + failure(message, exitCode: 2, command: command, json: json) + } + + static func failure( + _ message: String, exitCode: Int32, command: String?, json: Bool + ) -> CommandOutput { + if json { + let body = Failure(command: command, exitCode: exitCode, message: message) + return CommandOutput(exitCode: exitCode, stdout: encode(body) + "\n") + } + return CommandOutput(exitCode: exitCode, stderr: "error: \(message)\n") + } + + // MARK: Parsing + + /// The arguments that are not output flags, which may stand anywhere. + private static func withoutOutputFlags(_ arguments: [String]) -> [String] { + arguments.filter { !OutputForm.spellings.contains($0) } + } + + /// The usage error for words that name no command, when the arguments hold some: the first + /// word, or the words after `help`, which takes one. Arguments that start with an option + /// hold none. + private static func unknownCommand(in arguments: [String], json: Bool) -> CommandOutput? + { + let words = withoutOutputFlags(arguments) + let accepted = commandNames.joined(separator: ", ") + guard words.first == "help" else { + guard let word = words.first, !word.hasPrefix("-"), !commandNames.contains(word) + else { + return nil + } + return usage( + "unknown command `\(word)`; accepted: \(accepted)", command: nil, json: json) + } + // ArgumentParser's `help` takes any number of words and describes the first it knows. + let topics = words.dropFirst().filter { !$0.hasPrefix("-") } + if topics.count > 1 { + return usage( + "`help` takes one command, got `\(topics.joined(separator: " "))`; accepted: \(accepted)", + command: "help", json: json) + } + guard let topic = topics.first, !commandNames.contains(topic) else { + return nil + } + return usage( + "unknown command `\(topic)`; accepted: \(accepted)", command: "help", json: json) + } + + /// What ArgumentParser's error means here. + /// + /// A help request, a completion script, and the candidates of a custom completion are + /// errors to ArgumentParser and exit 0 with their text. Every other one is a usage error, + /// which ArgumentParser would exit 64 for, and exits 2 here. + private static func parseFailure( + _ error: any Error, arguments: [String], columns: Int?, json: Bool + ) -> CommandOutput { + guard Root.exitCode(for: error) != .success else { + let text = Root.fullMessage(for: error, columns: columns) + return CommandOutput(exitCode: 0, stdout: text.isEmpty ? "" : text + "\n") + } + // The message says what was wrong, on one line so the JSON form reads; the usage says + // what is accepted where it was. + let command = commands.first { + $0._commandName == withoutOutputFlags(arguments).first + } + let message = Root.message(for: error) + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .joined(separator: " ") + let synopsis = command.map { Root.usageString(for: $0) } ?? Root.usageString() + return usage( + "\(message); usage: \(synopsis)", command: command?._commandName, json: json) + } +} diff --git a/Sources/TesseraHarness/HeadlessHost.swift b/Sources/TesseraHarness/HeadlessHost.swift new file mode 100644 index 00000000..d34048b7 --- /dev/null +++ b/Sources/TesseraHarness/HeadlessHost.swift @@ -0,0 +1,130 @@ +import TesseraCore +import TesseraRuntime +import TesseraTerminal +import TesseraTerminalCore +import TesseraTerminalIO +import TesseraTerminalInput +import TesseraTerminalSnapshotSupport + +/// What a scenario runner or a snapshot test needs from a running application, without its +/// type. +@MainActor +package protocol ScenarioHost: AnyObject { + /// The size the application is laid out at. + var size: TerminalSize { get } + + /// Whether the application has asked its host to stop. + var isFinished: Bool { get } + + /// The visible screen, one string per row, with trailing blanks trimmed. + var screenRows: [String] { get } + + /// The view graph's current diagnostics. + var diagnostics: GraphDiagnostics { get } + + /// Delivers one event and returns once the resulting frame is committed. + func send(_ event: InputEvent) async throws +} + +extension ScreenSnapshot { + /// One string per row, with trailing blanks trimmed. + var trimmedRows: [String] { + cells.map { row in + var text = String(row.map(\.character)) + while text.last == " " { + text.removeLast() + } + return text + } + } +} + +/// Runs an application on a terminal session over a headless device and reads the result from +/// one persistent virtual terminal. +/// +/// The session is the live one: frames reach it through the same ``Driver`` and the same +/// `TerminalSession.draw` as in a real terminal, and the bytes it writes are fed to the +/// virtual terminal, so the screen the host reports is what a terminal would show. +@MainActor +final class HeadlessHost: ScenarioHost { + /// No terminal modes, no synchronized output, and no colour: a headless frame is plain cells. + private static var configuration: TerminalApplicationConfiguration { + TerminalApplicationConfiguration( + modes: [], + synchronizedOutput: .disabled, + colorCapability: .force(.noColor) + ) + } + + private let driver: Driver + private let device: HeadlessTerminalDevice + private let terminal: VirtualTerminal + + var size: TerminalSize { + driver.size + } + + var isFinished: Bool { + driver.isFinished + } + + var screenRows: [String] { + terminal.snapshot().trimmedRows + } + + var diagnostics: GraphDiagnostics { + driver.graph.diagnostics + } + + private init( + driver: Driver, + device: HeadlessTerminalDevice, + terminal: VirtualTerminal + ) { + self.driver = driver + self.device = device + self.terminal = terminal + } + + /// Builds the application, opens a headless session at `viewport`, and runs `body` against + /// it once the first frame is drawn. + /// + /// The factory runs once, on the main actor, so every run starts from a fresh application. + /// The session closes when `body` returns or throws. + static func run( + name: String, + viewport: Viewport, + make: @MainActor () -> App, + body: @escaping @MainActor (any ScenarioHost) async throws -> Void + ) async throws { + let app = make() + let size = try viewport.size(for: app, named: name) + let device = HeadlessTerminalDevice(size: size) + let terminal = VirtualTerminal.ghosttyOrUnavailable( + cols: size.columns, rows: size.rows) + + // The session's scope hands this function value, not the generic driver, to the main + // actor, so the application's conformance never crosses out of it. + let scope: @MainActor (TerminalSession) async throws -> Void = { session in + let driver = Driver(app: app, size: size, session: session) + try await driver.start() + try await body(HeadlessHost(driver: driver, device: device, terminal: terminal)) + } + try await TerminalSession.withApplicationTerminal( + configuration: Self.configuration, + io: PlatformIO(terminalDevice: device.seam), + environment: [:] + ) { session in + session.outputTap = { terminal.feed($0) } + try await scope(session) + } + } + + func send(_ event: InputEvent) async throws { + if case .resize(let newSize) = event { + await device.resize(to: newSize) + try terminal.resize(newSize) + } + try await driver.step(event) + } +} diff --git a/Sources/TesseraHarness/HeadlessTerminalDevice.swift b/Sources/TesseraHarness/HeadlessTerminalDevice.swift new file mode 100644 index 00000000..75da9d89 --- /dev/null +++ b/Sources/TesseraHarness/HeadlessTerminalDevice.swift @@ -0,0 +1,33 @@ +import TesseraTerminalCore +import TesseraTerminalIO + +/// The terminal device under a headless session: it reports a size and accepts writes. +/// +/// Nothing is read from it and nothing is kept: the session's output tap hands every drawn +/// frame to the virtual terminal before the write, and input reaches the application through +/// the driver, not through the device. A resize changes the size the next frame is drawn at. +/// +/// This type stands in for `InMemoryTerminalDevice` because that device lives in a module +/// that links the snapshot-testing library, and a library that links it cannot be loaded by +/// an executable. +actor HeadlessTerminalDevice { + private var size: TerminalSize + + /// The device seam a `PlatformIO` is built over. + nonisolated var seam: TerminalDevice { + TerminalDevice( + size: { await self.size }, + write: { $0.count } + ) + } + + /// Creates a device that reports `size`. + init(size: TerminalSize) { + self.size = size + } + + /// Changes the size the session reports from now on. + func resize(to size: TerminalSize) { + self.size = size + } +} diff --git a/Sources/TesseraHarness/LiveHost.swift b/Sources/TesseraHarness/LiveHost.swift new file mode 100644 index 00000000..bf536e30 --- /dev/null +++ b/Sources/TesseraHarness/LiveHost.swift @@ -0,0 +1,39 @@ +import TesseraRuntime +import TesseraTerminal + +/// Runs an application in the terminal this process is attached to. +/// +/// The live host opens a session over the real terminal device and steps the application +/// through the same ``Driver`` as the headless host. It has no quit key: it returns when the +/// application reports `isFinished` or the terminal's input ends. +@MainActor +enum LiveHost { + /// Builds the application, opens the terminal, and runs until the application finishes. + /// + /// The factory runs once, on the main actor, before the terminal changes mode. + static func run( + make: @MainActor () -> App, + configuration: TerminalApplicationConfiguration + ) async throws { + let app = make() + + // The session's scope hands this function value, not the generic driver, to the main + // actor, so the application's conformance never crosses out of it. + let loop: @MainActor (TerminalSession) async throws -> Void = { session in + let driver = Driver(app: app, size: try await session.size, session: session) + try await driver.start() + guard !driver.isFinished else { + return + } + for await event in session.events { + try await driver.step(event) + if driver.isFinished { + return + } + } + } + try await TerminalSession.withApplicationTerminal(configuration: configuration) { + try await loop($0) + } + } +} diff --git a/Sources/TesseraHarness/NaturalHeight.swift b/Sources/TesseraHarness/NaturalHeight.swift new file mode 100644 index 00000000..66993d78 --- /dev/null +++ b/Sources/TesseraHarness/NaturalHeight.swift @@ -0,0 +1,83 @@ +import TesseraCore +import TesseraRuntime +import TesseraTerminalCore + +/// Why an application's viewport could not be set up. +struct HostError: Error { + var verb: String + var message: String +} + +/// How an application's root measures when it is offered no height, and whether it fills +/// the height it is offered instead. +struct NaturalHeight: Equatable { + /// The shorter probe height. + static let shortProbe = 500 + + /// The taller probe height; a view that fills its proposal follows it. + static let tallProbe = 1_000 + + /// The rows the root measures to when no height is proposed. + var rows: Int + + /// The rows the root measured to at the shorter probe height. + var shortRows: Int + + /// The rows the root measured to at the taller probe height. + var tallRows: Int + + /// Whether the root grew with the proposal, so it has no natural height of its own. + /// + /// A scrolling viewport, a split, a spacer, and any stack that contains one answer with + /// the height they are offered. Two different offers give two different answers; content + /// of a fixed height gives the same answer to both. + var fillsProposal: Bool { + shortRows != tallRows + } + + /// Measures `app` at `columns` without drawing it. + /// + /// Reads `app.body` and nothing else: the application receives no event and keeps its + /// state, so the same instance can be run afterwards. + @MainActor + static func measure(_ app: App, columns: Int) -> Self { + let graph = ViewGraph( + root: { app.body }, + size: TerminalSize(columns: columns, rows: tallProbe) + ) + func rows(proposing height: Int?) -> Int { + graph.measuredRootSize(proposing: ProposedSize(width: columns, height: height)).rows + } + return Self( + rows: rows(proposing: nil), + shortRows: rows(proposing: shortProbe), + tallRows: rows(proposing: tallProbe) + ) + } +} + +extension Viewport { + /// The terminal size this viewport names for `app`. + /// + /// `mobile` is 40 columns at the height the content needs. An application that fills any + /// height has no such height, so the error says which viewport to use instead. + @MainActor + func size(for app: App, named name: String) throws(HostError) + -> TerminalSize + { + if let fixed = fixedSize { + return fixed + } + let natural = NaturalHeight.measure(app, columns: Self.mobileColumns) + guard !natural.fillsProposal else { + throw HostError( + verb: "size", + message: + "`\(name)` has no natural height: it measured \(natural.shortRows) rows when " + + "offered \(NaturalHeight.shortProbe) and \(natural.tallRows) when offered " + + "\(NaturalHeight.tallProbe). Use `size mobileScreen` (40x16) or an explicit WxH" + ) + } + return TerminalSize(columns: Self.mobileColumns, rows: max(1, natural.rows)) + } +} diff --git a/Sources/TesseraHarness/Registry.swift b/Sources/TesseraHarness/Registry.swift deleted file mode 100644 index afbb9b06..00000000 --- a/Sources/TesseraHarness/Registry.swift +++ /dev/null @@ -1,84 +0,0 @@ -import TesseraTerminalCore - -/// The content height a demo asks for at a given width when offered far more rows. -package struct NaturalHeight: Equatable, Sendable { - /// The rows the root node measured to under the tall proposal. - package var rows: Int - - /// The size the graph was laid out at while measuring. - package var probe: TerminalSize - - /// Whether the root grew to the whole proposal, so `rows` is the probe, not a natural height. - /// - /// A `ScrollView` or `SplitView` takes the proposed height, and so does any stack that - /// contains one; `GraphDiagnostics` cannot tell greedy from exactly-tall. - package var fillsProposal: Bool { - rows >= probe.rows - } -} - -/// Why a scenario's app or viewport could not be set up. -struct HostError: Error { - var verb: String - var message: String -} - -/// The single selection point from app name to driver. -package enum DemoRegistry { - package static let names = ["stack", "workspace"] - package static let defaultName = WorkspaceApp.name - - /// Rows offered while measuring natural height; content taller than this is clamped. - package static let probeRows = 500 - - static func makeHost(app: String, viewport: Viewport) throws(HostError) - -> any ScenarioHost - { - switch app { - case StackApp.name: try makeHost(StackApp.self, viewport: viewport) - case WorkspaceApp.name: try makeHost(WorkspaceApp.self, viewport: viewport) - default: - throw HostError(verb: "app", message: "app `\(app)` is not registered") - } - } - - package static func naturalHeight(app: String, width: Int) -> NaturalHeight? { - switch app { - case StackApp.name: naturalHeight(StackApp.self, width: width) - case WorkspaceApp.name: naturalHeight(WorkspaceApp.self, width: width) - default: nil - } - } - - private static func makeHost( - _ app: A.Type, - viewport: Viewport - ) throws(HostError) -> any ScenarioHost { - let size: TerminalSize - switch viewport { - case .desktop: size = TerminalSize(columns: 80, rows: 24) - case .mobileScreen: size = TerminalSize(columns: 40, rows: 16) - case .custom(let columns, let rows): size = TerminalSize(columns: columns, rows: rows) - case .mobile: - let natural = naturalHeight(app, width: 40) - guard !natural.fillsProposal else { - throw HostError( - verb: "size", - message: - "`\(A.name)` fills a \(natural.probe.rows)-row proposal, so it has no natural " - + "height; use `size mobileScreen` or an explicit WxH") - } - size = TerminalSize(columns: 40, rows: natural.rows) - } - return Driver(size: size) - } - - private static func naturalHeight(_ app: A.Type, width: Int) - -> NaturalHeight - { - let probe = TerminalSize(columns: width, rows: probeRows) - let driver = Driver(size: probe) - let root = driver.diagnostics.nodes.first { $0.parentIdentity == nil } - return NaturalHeight(rows: root?.measuredSize?.rows ?? 0, probe: probe) - } -} diff --git a/Sources/TesseraHarness/Scenarios/Runner.swift b/Sources/TesseraHarness/Scenarios/Runner.swift index 9b036ab9..09a0c45e 100644 --- a/Sources/TesseraHarness/Scenarios/Runner.swift +++ b/Sources/TesseraHarness/Scenarios/Runner.swift @@ -2,38 +2,87 @@ import TesseraCore import TesseraTerminalCore import TesseraTerminalInput -/// Executes a parsed scenario against a ``DemoRegistry`` app. +/// Executes a parsed scenario against an application in an ``ApplicationRegistry``. /// /// A failing `expect` is recorded with the screen at that moment and the run continues. +@MainActor package enum ScenarioRunner { - package static func run(_ scenario: Scenario, name: String) -> ScenarioReport { - execute(scenario, name: name).report + /// What a played scenario collected. + private final class Outcome { + var artifacts: [ShotArtifact] = [] + var failures: [ScenarioFailure] = [] + var observations: [ScenarioObservation] = [] + } + + package static func run( + _ scenario: Scenario, + name: String, + registry: ApplicationRegistry + ) async -> ScenarioReport { + await execute(scenario, name: name, registry: registry).report } static func execute( _ scenario: Scenario, - name: String - ) -> (report: ScenarioReport, artifacts: [ShotArtifact]) { - let host: any ScenarioHost - do { - host = try DemoRegistry.makeHost(app: scenario.app, viewport: scenario.viewport) - } catch { + name: String, + registry: ApplicationRegistry + ) async -> (report: ScenarioReport, artifacts: [ShotArtifact]) { + func setupFailure( + verb: String, message: String + ) -> (report: ScenarioReport, artifacts: [ShotArtifact]) { let report = ScenarioReport( scenario: name, passed: false, failures: [ ScenarioFailure( - line: 0, statement: "app \(scenario.app)", verb: error.verb, - message: error.message) + line: 0, statement: "app \(scenario.app)", verb: verb, message: message) ], observations: [] ) return (report, []) } + let entry: ApplicationRegistry.Entry + do { + entry = try registry.entry(named: scenario.app) + } catch { + return setupFailure(verb: "app", message: error.description) + } + + let outcome = Outcome() + do { + try await entry.headless(scenario.viewport) { host in + await play(scenario, on: host, into: outcome) + } + } catch let error as HostError { + return setupFailure(verb: error.verb, message: error.message) + } catch { + return setupFailure(verb: "harness", message: "\(error)") + } + + let report = ScenarioReport( + scenario: name, + passed: outcome.failures.isEmpty, + failures: outcome.failures, + observations: outcome.observations + ) + return (report, outcome.artifacts) + } + + /// Plays every statement against `host`, stopping early only when the host fails. + private static func play( + _ scenario: Scenario, + on host: any ScenarioHost, + into outcome: Outcome + ) async { var artifacts: [ShotArtifact] = [] var failures: [ScenarioFailure] = [] var observations: [ScenarioObservation] = [] + defer { + outcome.artifacts = artifacts + outcome.failures = failures + outcome.observations = observations + } for line in scenario.lines { func failure( @@ -50,16 +99,32 @@ package enum ScenarioRunner { ) } + func deliver(_ event: InputEvent) async -> Bool { + do { + try await host.send(event) + return true + } catch { + failures.append(failure("the host could not deliver the event: \(error)")) + return false + } + } + switch line.statement { case .resize(let columns, let rows): - host.send(.resize(TerminalSize(columns: columns, rows: rows))) + guard await deliver(.resize(TerminalSize(columns: columns, rows: rows))) else { + return + } case .key(let key): - host.send(.key(key)) + guard await deliver(.key(key)) else { + return + } case .type(let text): for character in text { - host.send(.key(Key(code: .character(character)))) + guard await deliver(.key(Key(code: .character(character)))) else { + return + } } case .shot(let shotName): @@ -130,14 +195,6 @@ package enum ScenarioRunner { } } } - - let report = ScenarioReport( - scenario: name, - passed: failures.isEmpty, - failures: failures, - observations: observations - ) - return (report, artifacts) } /// Rows with trailing empty rows dropped, so a 500-row probe stays readable. diff --git a/Sources/TesseraHarness/Scenarios/Scenario.swift b/Sources/TesseraHarness/Scenarios/Scenario.swift index bb384a4c..ce6a6392 100644 --- a/Sources/TesseraHarness/Scenarios/Scenario.swift +++ b/Sources/TesseraHarness/Scenarios/Scenario.swift @@ -2,21 +2,6 @@ import Foundation import TesseraTerminalCore import TesseraTerminalInput -/// A terminal size request from a `size` or `resize` statement. -package enum Viewport: Equatable, Sendable { - /// An explicit size. - case custom(columns: Int, rows: Int) - - /// 80x24. - case desktop - - /// 40 columns at the natural height the driver measures. - case mobile - - /// 40 columns by 16 rows, for screen-filling demos. - case mobileScreen -} - /// A rectangle written as `x,y,wxh` in `expect node … frame`. package struct FrameExpectation: CustomStringConvertible, Equatable, Sendable { package var column: Int @@ -62,7 +47,7 @@ package struct ScenarioLine: Equatable, Sendable { /// A parsed scenario: a header followed by an ordered list of statements. package struct Scenario: Equatable, Sendable { - /// The registered app name; defaults to ``DemoRegistry/defaultName``. + /// The registered app name, from the `app NAME` header. package var app: String package var viewport: Viewport package var lines: [ScenarioLine] @@ -103,9 +88,9 @@ package enum ScenarioParser { package static func parse( _ source: String, file: String, - knownApps: [String] = DemoRegistry.names + knownApps: [String] ) throws -> Scenario { - var app = DemoRegistry.defaultName + var app: String? var viewport = Viewport.desktop var lines: [ScenarioLine] = [] var shotNames: Set = [] @@ -168,7 +153,7 @@ package enum ScenarioParser { "bad size `\(arguments[0])`; expected WxH, desktop, mobile, or mobileScreen" ) } - viewport = .custom(columns: columns, rows: rows) + viewport = .custom(TerminalSize(columns: columns, rows: rows)) } case "resize": @@ -226,6 +211,14 @@ package enum ScenarioParser { } } + guard let app else { + throw ScenarioParseError( + file: file, line: 1, + message: + "a scenario names its app with an `app NAME` header; accepted: " + + knownApps.joined(separator: ", ") + ) + } return Scenario(app: app, viewport: viewport, lines: lines) } diff --git a/Sources/TesseraHarness/StandardStreams.swift b/Sources/TesseraHarness/StandardStreams.swift new file mode 100644 index 00000000..c8c44b68 --- /dev/null +++ b/Sources/TesseraHarness/StandardStreams.swift @@ -0,0 +1,28 @@ +import Foundation + +#if os(Windows) + import WinSDK +#endif + +/// The one platform question the command line asks: is a standard stream a terminal. +enum StandardStreams { + /// Whether standard input is attached to a terminal or console. + static var inputIsTerminal: Bool { + #if os(Windows) + var mode: DWORD = 0 + return GetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), &mode) + #else + return isatty(STDIN_FILENO) != 0 + #endif + } + + /// Whether standard output is attached to a terminal or console. + static var outputIsTerminal: Bool { + #if os(Windows) + var mode: DWORD = 0 + return GetConsoleMode(GetStdHandle(STD_OUTPUT_HANDLE), &mode) + #else + return isatty(STDOUT_FILENO) != 0 + #endif + } +} diff --git a/Sources/TesseraHarness/Viewport.swift b/Sources/TesseraHarness/Viewport.swift new file mode 100644 index 00000000..8a94e01f --- /dev/null +++ b/Sources/TesseraHarness/Viewport.swift @@ -0,0 +1,45 @@ +import TesseraTerminalCore + +/// A named terminal size that an application is run and checked at. +public enum Viewport: Hashable, Sendable { + /// An explicit size. + case custom(TerminalSize) + + /// 80 columns by 24 rows. + case desktop + + /// 40 columns at the height the application's content needs. + /// + /// The harness measures that natural height. An application whose content grows to any + /// height it is offered has none, so running it at `mobile` fails with an error that names + /// ``mobileScreen``. + case mobile + + /// 40 columns by 16 rows, for compositions meant to fill a screen. + case mobileScreen +} + +extension Viewport { + /// The width of ``mobile`` and ``mobileScreen``. + package static let mobileColumns = 40 + + /// The size a viewport names, or `nil` for ``mobile``, whose height must be measured. + package var fixedSize: TerminalSize? { + switch self { + case .custom(let size): size + case .desktop: TerminalSize(columns: 80, rows: 24) + case .mobile: nil + case .mobileScreen: TerminalSize(columns: Self.mobileColumns, rows: 16) + } + } + + /// The spelling a scenario header and the `apps` listing use. + package var name: String { + switch self { + case .custom(let size): "\(size.columns)x\(size.rows)" + case .desktop: "desktop" + case .mobile: "mobile" + case .mobileScreen: "mobileScreen" + } + } +} diff --git a/Sources/TesseraRuntime/Application.swift b/Sources/TesseraRuntime/Application.swift index b993952b..d3e19fc2 100644 --- a/Sources/TesseraRuntime/Application.swift +++ b/Sources/TesseraRuntime/Application.swift @@ -98,7 +98,9 @@ public protocol Application: AnyObject { /// Applies an input event the view graph left unhandled to the application's model. /// /// A host calls this for each event whose disposition is `.ignored`, and for every - /// resize: a resize is never routed to views, so it always reaches the application. + /// resize: a resize is never routed to views, so it always reaches the application. A + /// host also delivers the size the application starts at as a resize, before the first + /// frame, so the application learns its size the way it learns every later one. func apply(_ event: InputEvent) } diff --git a/Sources/TesseraRuntime/Step.swift b/Sources/TesseraRuntime/Step.swift new file mode 100644 index 00000000..ea04c7eb --- /dev/null +++ b/Sources/TesseraRuntime/Step.swift @@ -0,0 +1,55 @@ +import TesseraCore +import TesseraTerminalInput + +/// The one loop body that every host runs once per input event. +/// +/// A host owns an application and a ``ViewGraph`` in its own isolation and calls +/// ``run(_:graph:event:isolation:)`` for each event it receives. The function is nonisolated +/// and takes that isolation as a parameter, so the compiler proves that the application is +/// only ever touched from the host that owns it: a main-actor host passes `MainActor.shared`, +/// which accepts an application with a main-actor isolated conformance, and a host on any +/// other actor passes `self`, which rejects it at the call site. There is no default for the +/// parameter, so a caller must name an isolation: a synchronous nonisolated caller cannot +/// compile the call, and an async caller that passes another actor hops to it. +package enum Step { + /// Delivers one input event to the graph and the application, then updates the graph. + /// + /// The order is fixed: + /// + /// 1. A `.resize` event resizes the graph first, so every later stage sees the new size. + /// 2. The graph dispatches the event to its views. + /// 3. The application applies the event when the graph left it `.ignored`, and always when it + /// is a resize. + /// 4. The graph updates, so the next render reflects whatever the application changed. + /// + /// A resize always reaches the application, whatever the graph answered. It describes the + /// terminal rather than the user, so it is never routed to a view, and a view that handled it + /// would otherwise hide the new size from the application's model. Events other than resizes + /// reach the application only when no view handled them. + /// + /// - Parameters: + /// - app: The application, owned by the host. + /// - graph: The view graph that lowers `app.body`, owned by the host. + /// - event: The input event to deliver. + /// - isolation: The host's isolation; the host passes its own. + /// - Returns: How the graph disposed of the event. + @discardableResult + package static func run( + _ app: App, + graph: ViewGraph, + event: InputEvent, + isolation: isolated any Actor + ) -> EventDisposition { + var isResize = false + if case .resize(let size) = event { + isResize = true + graph.resize(to: size) + } + let disposition = graph.dispatch(event) + if disposition == .ignored || isResize { + app.apply(event) + } + graph.update() + return disposition + } +} diff --git a/Tessera.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Tessera.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8b8456f4..0916a044 100644 --- a/Tessera.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Tessera.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "7d4787b5fc558956686efe2e69a827fed9c5c7b20843504f82358b49f3c67da1", + "originHash" : "18534bc83b9f77e44147a37db9eeed586a3341266bae8702186c85ad05014e49", "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, { "identity" : "swift-custom-dump", "kind" : "remoteSourceControl", diff --git a/Tests/CompileFailFixtures/ActorHostMainActorApp.swift b/Tests/CompileFailFixtures/ActorHostMainActorApp.swift new file mode 100644 index 00000000..38305894 --- /dev/null +++ b/Tests/CompileFailFixtures/ActorHostMainActorApp.swift @@ -0,0 +1,32 @@ +// This file is intentionally ill-typed. scripts/check-isolation-fixtures.sh verifies that an +// actor host cannot step an application whose conformance is main-actor isolated, and that +// the same application steps on a main-actor host (compiled with -D CONTROL). + +import TesseraCore +import TesseraRuntime +import TesseraTerminalInput + +/// A main-actor model with a main-actor isolated conformance. +@MainActor +final class Browser: @MainActor Application { + var body: some View { + Text("Browser") + } + + func apply(_ event: InputEvent) {} +} + +#if CONTROL + @MainActor + final class Host { + func step(_ app: Browser, graph: ViewGraph, event: InputEvent) { + Step.run(app, graph: graph, event: event, isolation: MainActor.shared) + } + } +#else + actor Host { + func step(_ app: Browser, graph: ViewGraph, event: InputEvent) { + Step.run(app, graph: graph, event: event, isolation: self) + } + } +#endif diff --git a/Tests/CompileFailFixtures/AdoptWithoutIsolation.swift b/Tests/CompileFailFixtures/AdoptWithoutIsolation.swift new file mode 100644 index 00000000..c87e68bf --- /dev/null +++ b/Tests/CompileFailFixtures/AdoptWithoutIsolation.swift @@ -0,0 +1,13 @@ +// This file is intentionally ill-typed in plain Swift 6 mode. scripts/check-isolation-fixtures.sh +// typechecks it as a second module that imports `JournalModel`, a module compiled with default +// main-actor isolation, and adopts `Journal` without `@MainActor` on the conformance. The +// control (-D CONTROL) writes `@MainActor` on the conformance and compiles. + +import JournalModel +import TesseraRuntime + +#if CONTROL + extension Journal: @MainActor Application {} +#else + extension Journal: Application {} +#endif diff --git a/Tests/CompileFailFixtures/DefaultIsolationJournal.swift b/Tests/CompileFailFixtures/DefaultIsolationJournal.swift new file mode 100644 index 00000000..3c11a57f --- /dev/null +++ b/Tests/CompileFailFixtures/DefaultIsolationJournal.swift @@ -0,0 +1,20 @@ +// Compiled by scripts/check-isolation-fixtures.sh as the module `JournalModel`, with +// `-default-isolation MainActor`, the way an app module built from an Xcode 26 template is. +// It declares `Journal` without the `Application` conformance: the second module adopts it. + +import TesseraCore +import TesseraTerminalInput + +/// A type that is main-actor isolated only through its module's default isolation. +public final class Journal { + /// The root view. + public var body: some View { + Text("Journal") + } + + /// Creates a journal. + public init() {} + + /// Ignores every event. + public func apply(_ event: InputEvent) {} +} diff --git a/Tests/TesseraCoreTests/RootMeasurementTests.swift b/Tests/TesseraCoreTests/RootMeasurementTests.swift new file mode 100644 index 00000000..2a991758 --- /dev/null +++ b/Tests/TesseraCoreTests/RootMeasurementTests.swift @@ -0,0 +1,68 @@ +import TesseraTerminalCore +import TesseraTestSupport +import Testing + +@testable import TesseraCore + +/// A leaf that takes the height it is offered, and three rows when it is offered none. +private struct FillingLeaf: LeafView { + func sizeThatFits( + _ proposal: ProposedSize, + state: inout Void, + environment: EnvironmentValues + ) -> TerminalSize { + TerminalSize(columns: proposal.width ?? 1, rows: proposal.height ?? 3) + } + + func render( + in region: inout RenderRegion, state: inout Void, environment: EnvironmentValues + ) {} +} + +private let size = TerminalSize(columns: 20, rows: 6) + +@Test +func `a nil height proposal measures the content and a finite one measures the offer`() { + let graph = ViewGraph(root: { FillingLeaf() }, size: size) + + let measured = [nil, 500, 1_000].map { + graph.measuredRootSize(proposing: ProposedSize(width: 10, height: $0)) + } + + #expect( + measured == [ + TerminalSize(columns: 10, rows: 3), + TerminalSize(columns: 10, rows: 500), + TerminalSize(columns: 10, rows: 1_000), + ]) +} + +@Test +func `fixed-height content answers every height the same way`() { + let graph = ViewGraph(root: { Text("two\nlines") }, size: size) + + let short = graph.measuredRootSize(proposing: ProposedSize(width: 10, height: 500)) + let tall = graph.measuredRootSize(proposing: ProposedSize(width: 10, height: 1_000)) + + #expect(short == tall) + #expect(short.rows == 2) +} + +@Test +func `measuring leaves the statistics alone and makes the next pass measure again`() { + let graph = ViewGraph(root: { Text("steady") }, size: size) + _ = withTestFrame(size: size) { graph.render(into: $0) } + #expect(graph.needsLayout == false) + let measurements = graph.statistics.measurements + + _ = graph.measuredRootSize(proposing: ProposedSize(width: 3, height: nil)) + + #expect(graph.statistics.measurements == measurements) + #expect(graph.needsLayout) + #expect(graph.needsRender) + _ = withTestFrame(size: size) { graph.render(into: $0) } + #expect(graph.needsLayout == false) + #expect( + graph.diagnostics.nodes.first { $0.parentIdentity == nil }?.measuredSize?.columns == 6 + ) +} diff --git a/Tests/TesseraHarnessTests/DriverTests.swift b/Tests/TesseraHarnessTests/DriverTests.swift new file mode 100644 index 00000000..5424dedf --- /dev/null +++ b/Tests/TesseraHarnessTests/DriverTests.swift @@ -0,0 +1,240 @@ +import CustomDump +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump +import TesseraCore +import TesseraRuntime +import TesseraTerminal +import TesseraTerminalCore +import TesseraTerminalInput +import TesseraTerminalTestSupport +import Testing + +@testable import TesseraHarness + +@MainActor +struct DriverTests { + private static let configuration = TerminalApplicationConfiguration( + modes: [.rawMode, .altScreen], + synchronizedOutput: .disabled, + colorCapability: .force(.noColor) + ) + + private func makeDevice(_ size: TerminalSize) -> InMemoryTerminalDevice { + InMemoryTerminalDevice( + size: size, cellPixelSize: nil, inputBytes: [], keepsInputOpen: true) + } + + /// Opens a session over `device`, runs `scope` on the main actor, and closes the session. + /// + /// The device stays readable after the session's scope closes. + private func inSession( + over device: InMemoryTerminalDevice, + _ scope: @escaping @MainActor (TerminalSession) async throws -> Void + ) async throws { + try await InMemoryTerminalSession(device: device).withApplicationTerminal( + configuration: Self.configuration + ) { session in + do { + try await scope(session) + } catch { + await device.closeInput() + throw error + } + await device.closeInput() + } + } + + @Test + func `a view graph renders through draw into an in-memory device and the bytes arrive`() + async throws + { + let size = TerminalSize(columns: 12, rows: 2) + let device = makeDevice(size) + + try await inSession(over: device) { session in + let driver = Driver(app: CounterApp(), size: size, session: session) + try await driver.start() + try await driver.step(.key(Key(code: .character("+")))) + } + + let output = try #require(String(bytes: await device.bytes, encoding: .utf8)) + assertInlineSnapshot(of: output.debugDescription, as: .lines) { + #""" + "\u{1B}[?1049h\u{1B}[2J\u{1B}[1;1H\u{1B}[0mCount: 0 \u{1B}[2;1H \u{1B}[0m\u{1B}[?25l\u{1B}[1;8H1\u{1B}[0m\u{1B}[?25l\u{1B}[?25h\u{1B}_Ga=d,d=A\u{1B}\\\u{1B}[?1049l" + """# + } + let closing = Array(await device.events.suffix(2)) + expectNoDifference(closing, [.exitAltScreen, .exitRawMode]) + } + + @Test + func `the first frame is drawn at start and carries the starting size to the app`() + async throws + { + let size = TerminalSize(columns: 50, rows: 12) + let device = makeDevice(size) + + try await inSession(over: device) { session in + try await Driver(app: WorkspaceApp(), size: size, session: session).start() + } + + let output = try #require(String(bytes: await device.bytes, encoding: .utf8)) + #expect(output.contains("Workspace 50x12")) + } + + @Test + func `a step that leaves the graph clean writes no frame`() async throws { + let size = TerminalSize(columns: 12, rows: 2) + let device = makeDevice(size) + var counts: [Int] = [] + + try await inSession(over: device) { session in + let driver = Driver(app: StillApp(), size: size, session: session) + try await driver.start() + counts.append(await device.bytes.count) + try await driver.step(.key(Key(code: .character("x")))) + #expect(driver.graph.needsRender == false) + counts.append(await device.bytes.count) + } + + #expect(counts.count == 2) + #expect(counts.first ?? 0 > 0) + #expect(counts.first == counts.last) + } + + @Test + func `the host stops after the step that sets isFinished`() async throws { + let size = TerminalSize(columns: 12, rows: 2) + let app = CounterApp() + + try await inSession(over: makeDevice(size)) { session in + let driver = Driver(app: app, size: size, session: session) + try await driver.start() + #expect(driver.isFinished == false) + try await driver.step(.key(Key(code: .character("q")))) + #expect(driver.isFinished) + } + + #expect(app.finished) + } + + @Test + func `a resize event resizes the graph and reaches the app`() async throws { + var rows: [String] = [] + var size = TerminalSize(columns: 1, rows: 1) + let make: @MainActor () -> WorkspaceApp = { WorkspaceApp() } + + try await HeadlessHost.run( + name: "workspace", viewport: .desktop, make: make + ) { host in + try await host.send(.resize(TerminalSize(columns: 50, rows: 12))) + rows = host.screenRows + size = host.size + } + + #expect(size == TerminalSize(columns: 50, rows: 12)) + assertInlineSnapshot(of: rows.joined(separator: "\n"), as: .lines) { + """ + Workspace 50x12 + ────────────────────────────────────────────────── + Typed: + Key: none + Count: 0 + + + + + + ────────────────────────────────────────────────── + focus=catalog scroll=0 + """ + } + } + + @Test(arguments: [ + ("counter", "Count: 1"), + ("main-actor-counter", "Main-actor count: 1"), + ("observed-counter", "Observed count: 1"), + ]) + func `every adoption shape runs headless through the registry`( + name: String, expected: String + ) async throws { + var firstRow = "" + + try await Fixtures.registry().withHeadlessHost(name, at: .desktop) { host in + try await host.send(.key(Key(code: .character("+")))) + firstRow = host.screenRows.first ?? "" + } + + #expect(firstRow == expected) + } + + @Test + func `the headless screen is what the live draw path wrote`() async throws { + var rows: [String] = [] + let make: @MainActor () -> CounterApp = { CounterApp() } + + try await HeadlessHost.run( + name: "counter", viewport: .custom(TerminalSize(columns: 20, rows: 3)), make: make + ) { host in + try await host.send(.key(Key(code: .character("+")))) + try await host.send(.key(Key(code: .character("+")))) + rows = host.screenRows + } + + expectNoDifference(rows, ["Count: 2", "", ""]) + } +} + +@MainActor +struct StepHostingTests { + @Test + func + `a nonisolated app steps on a per-test actor and on the main actor through one step`() + async throws + { + let size = TerminalSize(columns: 20, rows: 2) + let plus = InputEvent.key(Key(code: .character("+"))) + + let host = await ActorHost(size: size) { CounterApp() } + await host.step(plus) + await host.step(plus) + let actorCount = await host.read { $0.count } + let actorRows = await host.screenRows() + + let app = CounterApp() + let graph = ViewGraph(root: { app.body }, size: size) + Step.run(app, graph: graph, event: plus, isolation: MainActor.shared) + Step.run(app, graph: graph, event: plus, isolation: MainActor.shared) + + #expect(actorCount == 2) + #expect(app.count == 2) + #expect(actorRows.first == "Count: 2") + } + + @Test + func `an app with a main-actor model steps through the same step on the main actor`() { + let app = MainActorCounterApp() + let graph = ViewGraph(root: { app.body }, size: TerminalSize(columns: 30, rows: 2)) + + Step.run( + app, graph: graph, event: .key(Key(code: .character("+"))), + isolation: MainActor.shared) + + #expect(app.count == 1) + } + + @Test + func `an observable app steps like any other`() async { + let host = await ActorHost( + size: TerminalSize(columns: 30, rows: 2) + ) { + ObservedCounterApp() + } + + await host.step(.key(Key(code: .character("+")))) + + #expect(await host.screenRows().first == "Observed count: 1") + } +} diff --git a/Sources/TesseraHarness/Demos.swift b/Tests/TesseraHarnessTests/Fixtures.swift similarity index 51% rename from Sources/TesseraHarness/Demos.swift rename to Tests/TesseraHarnessTests/Fixtures.swift index dd51c9e6..09d00f52 100644 --- a/Sources/TesseraHarness/Demos.swift +++ b/Tests/TesseraHarnessTests/Fixtures.swift @@ -1,14 +1,14 @@ +import Observation import TesseraCore import TesseraLayout +import TesseraRuntime import TesseraTerminalCore import TesseraTerminalInput import TesseraWidgets -/// A responsive workspace: header, a split of a scrolling catalog and an inspector, a -/// status line. All input state is demo-owned because `ViewGraph.dispatch` routes no input to views. -package final class WorkspaceApp: DemoApp { - package static let name = "workspace" +@testable import TesseraHarness +final class WorkspaceApp: Application { private static let catalogRows = 30 private static let catalogText = (0.. ApplicationRegistry { + var registry = ApplicationRegistry() + registry.register("stack", viewports: [.desktop, .mobile]) { StackApp() } + registry.register("workspace", viewports: [.desktop, .mobileScreen]) { + WorkspaceApp() + } + registry.register("greedy", viewports: [.desktop, .mobileScreen]) { GreedyApp() } + registry.register( + "counter", minimum: TerminalSize(columns: 20, rows: 1), viewports: [.desktop] + ) { + CounterApp() + } + registry.register("main-actor-counter", viewports: [.desktop]) { + MainActorCounterApp() + } + registry.register("observed-counter", viewports: [.desktop]) { ObservedCounterApp() } + return registry + } +} diff --git a/Tests/TesseraHarnessTests/HarnessCLITests.swift b/Tests/TesseraHarnessTests/HarnessCLITests.swift new file mode 100644 index 00000000..5b509610 --- /dev/null +++ b/Tests/TesseraHarnessTests/HarnessCLITests.swift @@ -0,0 +1,654 @@ +import CustomDump +import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump +import TesseraTerminal +import TesseraTerminalCore +import Testing + +@testable import TesseraHarness + +/// What a shell's completion script sends to ask for the candidates of a command's app name: +/// the command, `--`, the key of the argument (`positional@0` for a positional, the flag for +/// an option), the index of the word being completed, the cursor in it, and the words so +/// far, the executable first. +private let completionRequests: [[String]] = [ + ["launch", "--", "positional@0", "2", "0", "tessera", "launch", ""] +] + +@MainActor +struct HarnessCLITests { + /// Runs a command. `terminal` says standard output is a terminal and `input` says standard + /// input is; neither defaults to `true`, so no test opens a live session by accident. + private func run( + _ arguments: String..., + registry: ApplicationRegistry? = nil, + terminal: Bool = false, + input: Bool = false, + executable: String = "tessera" + ) async -> CommandOutput { + await run( + arguments: arguments, registry: registry, terminal: terminal, input: input, + executable: executable) + } + + private func run( + arguments: [String], + registry: ApplicationRegistry? = nil, + terminal: Bool = false, + input: Bool = false, + executable: String = "tessera" + ) async -> CommandOutput { + await HarnessCLI.run( + arguments: arguments, + registry: registry ?? Fixtures.registry(), + inputIsTerminal: input, + outputIsTerminal: terminal, + configuration: .default, + executableName: executable + ) + } + + private func duplicated() -> ApplicationRegistry { + var registry = Fixtures.registry() + registry.register("counter", viewports: [.mobile]) { CounterApp() } + return registry + } + + @Test + func `apps prints one JSON object when stdout is not a terminal`() async { + let output = await run("apps") + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + {"apps":[{"minimum":"20x1","name":"counter","viewports":["desktop"]},{"minimum":null,"name":"greedy","viewports":["desktop","mobileScreen"]},{"minimum":null,"name":"main-actor-counter","viewports":["desktop"]},{"minimum":null,"name":"observed-counter","viewports":["desktop"]},{"minimum":null,"name":"stack","viewports":["desktop","mobile"]},{"minimum":null,"name":"workspace","viewports":["desktop","mobileScreen"]}],"command":"apps"} + + """, + stderr: "" + ) + """# + } + } + + @Test + func `an app with no declared minimum lists it as null so every entry has three keys`() + async throws + { + let output = await run("apps") + + let object = try #require( + JSONSerialization.jsonObject(with: Data(output.stdout.utf8)) as? [String: Any]) + let apps = try #require(object["apps"] as? [[String: Any]]) + let stack = try #require(apps.first { $0["name"] as? String == "stack" }) + expectNoDifference( + Set(apps.map { $0.keys.sorted() }), [["minimum", "name", "viewports"]]) + #expect(stack["minimum"] is NSNull) + } + + @Test + func `apps prints a table on a terminal and --json forces the object`() async { + let table = await run("apps", terminal: true) + let forced = await run("apps", "--json", terminal: true) + let piped = await run("apps") + + assertInlineSnapshot(of: table, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + NAME MINIMUM VIEWPORTS + counter 20x1 desktop + greedy - desktop, mobileScreen + main-actor-counter - desktop + observed-counter - desktop + stack - desktop, mobile + workspace - desktop, mobileScreen + + """, + stderr: "" + ) + """# + } + expectNoDifference(forced, piped) + } + + @Test + func `--pretty forces the table when stdout is not a terminal`() async { + let output = await run("apps", "--pretty") + let table = await run("apps", terminal: true) + + expectNoDifference(output, table) + } + + @Test + func `apps output is identical across runs`() async { + let first = await run("apps") + let second = await run("apps") + + #expect(first == second) + } + + @Test + func `an empty registry lists nothing`() async { + let output = await run("apps", "--pretty", registry: ApplicationRegistry()) + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + no applications are registered + + """, + stderr: "" + ) + """# + } + } + + @Test + func `a duplicate name fails apps and launch with exit 3`() async { + let registry = duplicated() + + let apps = await run("apps", "--pretty", registry: registry) + let launch = await run( + "launch", "stack", "--pretty", registry: registry, terminal: true) + + assertInlineSnapshot(of: [apps, launch], as: .customDump) { + #""" + [ + [0]: CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: applications registered more than once: `counter` + + """ + ), + [1]: CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: applications registered more than once: `counter` + + """ + ) + ] + """# + } + } + + @Test + func `launch with an unknown name is a usage error that lists the accepted names`() + async + { + let output = await run("launch", "ghost", "--pretty", terminal: true) + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown app `ghost`; accepted: counter, greedy, main-actor-counter, observed-counter, stack, workspace + + """ + ) + """# + } + } + + @Test + func `launch without a name is a usage error`() async { + let output = await run("launch", "--pretty", terminal: true) + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: Missing expected argument ''; usage: tessera launch [--json] [--pretty] + + """ + ) + """# + } + } + + @Test + func `launch refuses to start unless stdout is a terminal`() async { + let output = await run("launch", "counter", input: true) + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 3, + stdout: """ + {"command":"launch","exitCode":3,"message":"`launch` runs an application in this terminal, and standard output is not one"} + + """, + stderr: "" + ) + """# + } + } + + @Test + func + `launch refuses to start unless stdin is a terminal and says so before any session`() + async + { + let output = await run("launch", "counter", "--pretty", terminal: true, input: false) + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: `launch` runs an application in this terminal, and standard input is not one + + """ + ) + """# + } + } + + @Test + func `launch names both streams when neither is a terminal`() async { + let output = await run("launch", "counter", "--pretty") + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: `launch` runs an application in this terminal, and standard input and output are not terminals + + """ + ) + """# + } + } + + @Test + func `an unknown command has a null command in its JSON failure`() async { + let output = await run("jump") + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: """ + {"command":null,"exitCode":2,"message":"unknown command `jump`; accepted: apps, launch, help"} + + """, + stderr: "" + ) + """# + } + } + + @Test + func `an unknown command lists the accepted commands`() async { + let output = await run("jump", "--pretty") + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown command `jump`; accepted: apps, launch, help + + """ + ) + """# + } + } + + @Test + func `no command is a usage error`() async { + let output = await run("--pretty") + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: a command is required; accepted: apps, launch, help + + """ + ) + """# + } + } + + @Test + func `help prints the overview and each command's help whatever stdout is`() async { + let outputs = [ + await run("help"), await run("help", "apps"), await run("help", "launch"), + ] + + assertInlineSnapshot(of: outputs, as: .customDump) { + #""" + [ + [0]: CommandOutput( + exitCode: 0, + stdout: """ + OVERVIEW: List and launch the registered applications. + + Every command exits 0 on success, 2 on a usage error, and 3 on a harness error. + When standard output is not a terminal each command writes one JSON object to + it; --json forces that form and --pretty forces the text form. + + `--generate-completion-script SHELL` prints a completion script for fish, zsh, + or bash. It completes the commands, their flags, and the registered application + names. + + USAGE: tessera [--json] [--pretty] + + OPTIONS: + --json Print one JSON object, even on a terminal. + --pretty Print the text form, even when output is not a + terminal. + -h, --help Show help information. + + SUBCOMMANDS: + apps List the registered applications. + launch Run one application in this terminal. + + See 'tessera help ' for detailed help. + + """, + stderr: "" + ), + [1]: CommandOutput( + exitCode: 0, + stdout: """ + OVERVIEW: List the registered applications. + + Lists every registered application with the smallest size it declares and the + viewports it is checked at. The JSON form is {"apps": [{"minimum", "name", + "viewports"}], "command": "apps"}, sorted by name, where "minimum" is null for + an application that declares none. A name registered twice fails the command + with exit 3. + + USAGE: tessera apps [--json] [--pretty] + + OPTIONS: + --json Print one JSON object, even on a terminal. + --pretty Print the text form, even when output is not a + terminal. + -h, --help Show help information. + + + """, + stderr: "" + ), + [2]: CommandOutput( + exitCode: 0, + stdout: """ + OVERVIEW: Run one application in this terminal. + + Runs the named application at the terminal's size, until the application + finishes. The harness has no quit key of its own: the application decides which + key quits. + + Exits 2 for an unknown name, and 3 for a duplicate registration or when + standard input or output is not a terminal. + + USAGE: tessera launch [--json] [--pretty] + + ARGUMENTS: + The application to run, as `apps` lists it. + + OPTIONS: + --json Print one JSON object, even on a terminal. + --pretty Print the text form, even when output is not a + terminal. + -h, --help Show help information. + + + """, + stderr: "" + ) + ] + """# + } + } + + @Test + func `help COMMAND and COMMAND --help print the same text`() async { + let spelled = await run("help", "launch") + let flagged = await run("launch", "--help") + let overview = await run("help") + let flaggedOverview = await run("--help", terminal: true) + + expectNoDifference(spelled, flagged) + expectNoDifference(overview, flaggedOverview) + } + + @Test + func `usage names the executable it runs as`() async { + let output = await run("help", "launch", executable: "mytool") + + #expect(output.stdout.contains("USAGE: mytool launch")) + } + + @Test + func `help for an unknown or extra word is a usage error that lists the commands`() + async + { + let json = await run("help", "ghost") + let text = await run("help", "ghost", "--pretty") + let extra = await run("help", "launch", "extra", "--pretty") + let both = await run("help", "ghost", "launch", "--pretty") + + assertInlineSnapshot(of: [json, text, extra, both], as: .customDump) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: """ + {"command":"help","exitCode":2,"message":"unknown command `ghost`; accepted: apps, launch, help"} + + """, + stderr: "" + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown command `ghost`; accepted: apps, launch, help + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `help` takes one command, got `launch extra`; accepted: apps, launch, help + + """ + ), + [3]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `help` takes one command, got `ghost launch`; accepted: apps, launch, help + + """ + ) + ] + """# + } + } + + @Test + func `help help describes help as ArgumentParser words it`() async { + let output = await run("help", "help") + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + OVERVIEW: Show subcommand help information. + + USAGE: tessera help [ ...] + + ARGUMENTS: + + + + """, + stderr: "" + ) + """# + } + } + + @Test + func `the parser's usage errors exit 2, not its own 64, and say what is accepted`() + async + { + let outputs = [ + await run("apps", "extra", "--pretty"), + await run("apps", "--nope", "--pretty"), + await run("launch", "a", "b", "--pretty"), + await run("--generate-completion-script", "powershell", "--pretty"), + await run("--nope", "--pretty"), + ] + + assertInlineSnapshot(of: outputs, as: .customDump) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: Unexpected argument 'extra'; usage: tessera apps [--json] [--pretty] + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: Unknown option '--nope'; usage: tessera apps [--json] [--pretty] + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: Unexpected argument 'b'; usage: tessera launch [--json] [--pretty] + + """ + ), + [3]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: Can't generate completion scripts for 'powershell'. Please use --generate-completion-script= with one of: zsh bash fish; usage: tessera [--json] [--pretty] + + """ + ), + [4]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: Unknown option '--nope'; usage: tessera [--json] [--pretty] + + """ + ) + ] + """# + } + } + + @Test + func `a usage error is a JSON failure when stdout is not a terminal`() async { + let output = await run("launch") + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: """ + {"command":"launch","exitCode":2,"message":"Missing expected argument ''; usage: tessera launch [--json] [--pretty] "} + + """, + stderr: "" + ) + """# + } + } + + @Test + func `--json and --pretty together are a usage error`() async { + let output = await run("apps", "--json", "--pretty") + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: Value to be set with flag '--pretty' had already been set with flag '--json'; usage: tessera apps [--json] [--pretty] + + """ + ) + """# + } + } + + @Test + func `the output flags are accepted before or after the command`() async { + let before = await run("--json", "apps", terminal: true) + let after = await run("apps", "--json", terminal: true) + + expectNoDifference(before, after) + #expect(before.exitCode == 0) + } + + @Test(arguments: completionRequests) + func `every command that takes an app name completes the registered names`( + request: [String] + ) async throws { + let output = await run(arguments: ["---completion"] + request) + + let names = try Fixtures.registry().names() + expectNoDifference( + output, CommandOutput(exitCode: 0, stdout: names.map { $0 + "\n" }.joined())) + } + + @Test(arguments: completionRequests) + func `a registry with a duplicate name completes nothing`(request: [String]) async { + let output = await run(arguments: ["---completion"] + request, registry: duplicated()) + + expectNoDifference(output, CommandOutput(exitCode: 0)) + } + + @Test(arguments: ["bash", "fish", "zsh"]) + func `the completion script for a shell calls the executable by its name`( + shell: String + ) async { + let output = await run( + "--generate-completion-script", shell, executable: "mytool") + + #expect(output.exitCode == 0) + #expect(output.stderr.isEmpty) + #expect(output.stdout.contains("mytool")) + #expect(output.stdout.contains("---completion")) + } +} diff --git a/Tests/TesseraHarnessTests/RegistryTests.swift b/Tests/TesseraHarnessTests/RegistryTests.swift new file mode 100644 index 00000000..f3d951af --- /dev/null +++ b/Tests/TesseraHarnessTests/RegistryTests.swift @@ -0,0 +1,172 @@ +import CustomDump +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump +import TesseraTerminalCore +import Testing + +@testable import TesseraHarness + +@MainActor +struct ApplicationRegistryTests { + private func duplicated() -> ApplicationRegistry { + var registry = Fixtures.registry() + registry.register("counter", viewports: [.mobile]) { CounterApp() } + registry.register("stack", viewports: [.mobile]) { StackApp() } + return registry + } + + @Test + func `lookup returns the registration's name, minimum, and viewports`() throws { + let entry = try Fixtures.registry().entry(named: "counter") + + assertInlineSnapshot(of: entry, as: .customDump) { + """ + ApplicationRegistry.Entry( + name: "counter", + minimum: TerminalSize( + columns: 20, + rows: 1 + ), + viewports: [ + [0]: .desktop + ], + headless: (Function), + live: (Function) + ) + """ + } + } + + @Test + func `entries and names come back sorted by name`() throws { + let names = try Fixtures.registry().names() + + assertInlineSnapshot(of: names, as: .customDump) { + """ + [ + [0]: "counter", + [1]: "greedy", + [2]: "main-actor-counter", + [3]: "observed-counter", + [4]: "stack", + [5]: "workspace" + ] + """ + } + } + + @Test + func `a name that is not registered fails and lists the registered names`() { + let error = #expect(throws: RegistryError.self) { + try Fixtures.registry().entry(named: "ghost") + } + + assertInlineSnapshot(of: error, as: .customDump) { + """ + RegistryError.unknown( + "ghost", + accepted: [ + [0]: "counter", + [1]: "greedy", + [2]: "main-actor-counter", + [3]: "observed-counter", + [4]: "stack", + [5]: "workspace" + ] + ) + """ + } + assertInlineSnapshot(of: error?.description, as: .lines) { + """ + unknown app `ghost`; accepted: counter, greedy, main-actor-counter, observed-counter, stack, workspace + """ + } + } + + @Test + func `registering a name twice does not fail at the registration site`() { + let registry = duplicated() + + #expect(registry.entries.count == Fixtures.registry().entries.count + 2) + } + + @Test + func `a duplicate name fails every read of the registry, never last-wins`() { + let registry = duplicated() + let expected = RegistryError.duplicate(["counter", "stack"]) + + #expect(throws: expected) { try registry.checkedEntries() } + #expect(throws: expected) { try registry.names() } + #expect(throws: expected) { try registry.entry(named: "counter") } + #expect(throws: expected) { try registry.entry(named: "workspace") } + #expect( + expected.description == "applications registered more than once: `counter`, `stack`" + ) + } + + @Test + func `a duplicate name fails the scenario runner with the app verb`() async throws { + let scenario = Scenario(app: "workspace", viewport: .desktop, lines: []) + + let report = await ScenarioRunner.run(scenario, name: "t.scn", registry: duplicated()) + + assertInlineSnapshot(of: report, as: .customDump) { + """ + ScenarioReport( + scenario: "t.scn", + passed: false, + failures: [ + [0]: ScenarioFailure( + line: 0, + statement: "app workspace", + verb: "app", + message: "applications registered more than once: `counter`, `stack`", + expected: nil, + actual: nil, + screen: nil + ) + ], + observations: [] + ) + """ + } + } + + @Test + func `a registry with no applications lists nothing and finds nothing`() throws { + let registry = ApplicationRegistry() + + #expect(try registry.names().isEmpty) + #expect(throws: RegistryError.self) { try registry.entry(named: "any") } + } +} + +struct ViewportTests { + @Test + func `fixed viewports name their sizes and mobile has to be measured`() { + let viewports: [Viewport] = [ + .desktop, .mobileScreen, .custom(TerminalSize(columns: 30, rows: 9)), .mobile, + ] + + expectNoDifference( + viewports.map(\.fixedSize), + [ + TerminalSize(columns: 80, rows: 24), + TerminalSize(columns: 40, rows: 16), + TerminalSize(columns: 30, rows: 9), + nil, + ] + ) + } + + @Test + func `viewports are hashable and spell themselves the way a scenario header does`() { + let viewports: Set = [ + .desktop, .mobile, .mobileScreen, .custom(TerminalSize(columns: 30, rows: 9)), + ] + + expectNoDifference( + viewports.map(\.name).sorted(), ["30x9", "desktop", "mobile", "mobileScreen"]) + } +} diff --git a/Tests/TesseraHarnessTests/ScenarioTests.swift b/Tests/TesseraHarnessTests/ScenarioTests.swift index 11fc4581..5c42f35a 100644 --- a/Tests/TesseraHarnessTests/ScenarioTests.swift +++ b/Tests/TesseraHarnessTests/ScenarioTests.swift @@ -3,15 +3,20 @@ import Foundation import InlineSnapshotTesting import SnapshotTesting import SnapshotTestingCustomDump +import TesseraCore import TesseraTerminalInput import Testing @testable import TesseraHarness +private func parse(_ source: String, file: String = "t.scn") throws -> Scenario { + try ScenarioParser.parse(source, file: file, knownApps: Fixtures.names) +} + /// The text of the parse error `source` raises, in the `FILE:LINE: error: message` form. private func parseFailure(of source: String) -> String { do { - _ = try ScenarioParser.parse(source, file: "t.scn") + _ = try parse(source) Issue.record("`\(source)` parsed without an error") } catch let error as ScenarioParseError { return error.description @@ -24,14 +29,13 @@ private func parseFailure(of source: String) -> String { struct ScenarioParserTests { @Test func `header statements bind before the first body statement`() throws { - let scenario = try ScenarioParser.parse( + let scenario = try parse( """ # comment app stack size mobile type "hi there" # trailing comment - """, - file: "t.scn" + """ ) assertInlineSnapshot(of: scenario, as: .customDump) { @@ -51,6 +55,24 @@ struct ScenarioParserTests { } } + @Test + func `a scenario without an app header is a parse error naming the accepted apps`() { + assertInlineSnapshot(of: parseFailure(of: "size desktop\nkey Tab"), as: .lines) { + """ + t.scn:1: error: a scenario names its app with an `app NAME` header; accepted: stack, workspace, greedy + """ + } + } + + @Test + func `an app the registry does not hold is a parse error`() { + assertInlineSnapshot(of: parseFailure(of: "app missing"), as: .lines) { + """ + t.scn:1: error: unknown app `missing`; accepted: stack, workspace, greedy + """ + } + } + @Test func `size after a body statement names the resize verb`() { assertInlineSnapshot(of: parseFailure(of: "key Tab\nsize 10x10"), as: .lines) { @@ -89,10 +111,7 @@ struct ScenarioParserTests { @Test func `chords and function keys parse into key events`() throws { - let scenario = try ScenarioParser.parse( - "key Shift+Tab\nkey Ctrl+C\nkey F5\nkey Ctrl++", - file: "t.scn" - ) + let scenario = try parse("app stack\nkey Shift+Tab\nkey Ctrl+C\nkey F5\nkey Ctrl++") expectNoDifference( scenario.lines.map(\.statement), @@ -107,9 +126,8 @@ struct ScenarioParserTests { @Test func `frame expectations accept one token or a two-token form`() throws { - let scenario = try ScenarioParser.parse( - "expect node id=root frame 1,2,3x4\nexpect node id=root frame 1,2 3x4", - file: "t.scn" + let scenario = try parse( + "app stack\nexpect node id=root frame 1,2,3x4\nexpect node id=root frame 1,2 3x4" ) let expected = Statement.expectNode( @@ -139,14 +157,17 @@ struct ScenarioParserTests { } } +@MainActor struct ScenarioRunnerTests { - private func run(_ source: String) throws -> ScenarioReport { - ScenarioRunner.run(try ScenarioParser.parse(source, file: "t.scn"), name: "t.scn") + private func run(_ source: String) async throws -> ScenarioReport { + let scenario = try parse(source) + return await ScenarioRunner.run( + scenario, name: "t.scn", registry: Fixtures.registry()) } @Test - func `a wrong row expectation reports expected actual and the screen`() throws { - let report = try run("app stack\nsize 40x10\nexpect text 0 \"Stack demos\"") + func `a wrong row expectation reports expected actual and the screen`() async throws { + let report = try await run("app stack\nsize 40x10\nexpect text 0 \"Stack demos\"") assertInlineSnapshot(of: report, as: .customDump) { """ @@ -178,8 +199,8 @@ struct ScenarioRunnerTests { } @Test - func `the run continues after a failed expectation`() throws { - let report = try run( + func `the run continues after a failed expectation`() async throws { + let report = try await run( """ app stack size 40x10 @@ -220,8 +241,11 @@ struct ScenarioRunnerTests { } @Test - func `a locator that matches nothing is a failure that names the node types`() throws { - let report = try run("app stack\nsize 40x10\nexpect node type=Missing frame 0,0,1x1") + func `a locator that matches nothing is a failure that names the node types`() + async throws + { + let report = try await run( + "app stack\nsize 40x10\nexpect node type=Missing frame 0,0,1x1") assertInlineSnapshot(of: report.human(), as: .lines) { """ @@ -229,7 +253,7 @@ struct ScenarioRunnerTests { line 3: expect node type=Missing frame 0,0,1x1 locator `type=Missing` matches no node expected: 0,0,1x1 - actual: (no node; types: VStack, Text, Divider, HStack, Spacer) + actual: (no node; types: TupleView, VStack, Text, Divider, HStack, Spacer) screen: 0| Stack demo 1| ──────────────────────────────────────── @@ -242,8 +266,9 @@ struct ScenarioRunnerTests { } @Test - func `an ambiguous locator asks for nth`() throws { - let report = try run("app stack\nsize 40x10\nexpect node type=Text frame 0,0,1x1") + func `an ambiguous locator asks for nth`() async throws { + let report = try await run( + "app stack\nsize 40x10\nexpect node type=Text frame 0,0,1x1") assertInlineSnapshot(of: report.human(), as: .lines) { """ @@ -251,7 +276,7 @@ struct ScenarioRunnerTests { line 3: expect node type=Text frame 0,0,1x1 locator `type=Text` matches 6 nodes; add nth= expected: 0,0,1x1 - actual: root/index(0), root/index(2)/index(0), root/index(2)/index(1), root/index(3)/index(0), root/index(3)/index(2), root/index(5) + actual: root/index(0)/index(0), root/index(0)/index(2)/index(0), root/index(0)/index(2)/index(1), root/index(0)/index(3)/index(0), root/index(0)/index(3)/index(2), root/index(0)/index(5) screen: 0| Stack demo 1| ──────────────────────────────────────── @@ -264,16 +289,54 @@ struct ScenarioRunnerTests { } @Test - func `type feeds one key event per character into demo state`() throws { - let report = try run( + func `type feeds one key event per character into app state`() async throws { + let report = try await run( "app stack\nsize 40x10\ntype \"Rob\"\nkey Backspace\nexpect text 2 \"Name: Ro\"") expectNoDifference(report.failures, []) } @Test - func `mobile fails for a demo that fills its proposal`() throws { - let report = try run("app workspace\nsize mobile\nshot a") + func `mobile is 40 columns at the natural height of an app with one`() async throws { + let report = try await run( + "app stack\nsize mobile\nexpect node id=root frame 0,0,40x6") + + expectNoDifference(report.failures, []) + } + + @Test + func `mobile fails for an app that fills its proposal and names mobileScreen`() + async throws + { + let report = try await run("app greedy\nsize mobile\nshot a") + + assertInlineSnapshot(of: report, as: .customDump) { + """ + ScenarioReport( + scenario: "t.scn", + passed: false, + failures: [ + [0]: ScenarioFailure( + line: 0, + statement: "app greedy", + verb: "size", + message: "`greedy` has no natural height: it measured 500 rows when offered 500 and 1000 when offered 1000. Use `size mobileScreen` (40x16) or an explicit WxH", + expected: nil, + actual: nil, + screen: nil + ) + ], + observations: [] + ) + """ + } + } + + @Test + func `mobile fails for the workspace, whose viewport and split fill any height`() + async throws + { + let report = try await run("app workspace\nsize mobile\nshot a") assertInlineSnapshot(of: report, as: .customDump) { """ @@ -285,7 +348,7 @@ struct ScenarioRunnerTests { line: 0, statement: "app workspace", verb: "size", - message: "`workspace` fills a 500-row proposal, so it has no natural height; use `size mobileScreen` or an explicit WxH", + message: "`workspace` has no natural height: it measured 500 rows when offered 500 and 1000 when offered 1000. Use `size mobileScreen` (40x16) or an explicit WxH", expected: nil, actual: nil, screen: nil @@ -298,35 +361,94 @@ struct ScenarioRunnerTests { } @Test - func `mobile natural height is the root's measured rows under a tall proposal`() { - let stack = DemoRegistry.naturalHeight(app: "stack", width: 40) - let workspace = DemoRegistry.naturalHeight(app: "workspace", width: 40) + func `mobileScreen runs a greedy app at 40x16`() async throws { + let report = try await run( + "app greedy\nsize mobileScreen\nexpect node id=root frame 0,0,40x16") + + expectNoDifference(report.failures, []) + } + + @Test + func `an app the registry lacks fails the run with the app verb`() async throws { + let scenario = Scenario(app: "ghost", viewport: .desktop, lines: []) - #expect(stack?.rows == 6) - #expect(stack?.fillsProposal == false) - #expect(workspace?.fillsProposal == true) + let report = await ScenarioRunner.run( + scenario, name: "t.scn", registry: Fixtures.registry()) + + assertInlineSnapshot(of: report, as: .customDump) { + """ + ScenarioReport( + scenario: "t.scn", + passed: false, + failures: [ + [0]: ScenarioFailure( + line: 0, + statement: "app ghost", + verb: "app", + message: "unknown app `ghost`; accepted: counter, greedy, main-actor-counter, observed-counter, stack, workspace", + expected: nil, + actual: nil, + screen: nil + ) + ], + observations: [] + ) + """ + } + } + + @Test + func `natural height is the root's measured rows when no height is proposed`() { + let stack = NaturalHeight.measure(StackApp(), columns: 40) + let workspace = NaturalHeight.measure(WorkspaceApp(), columns: 40) + let greedy = NaturalHeight.measure(GreedyApp(), columns: 40) + + assertInlineSnapshot(of: [stack, workspace, greedy], as: .customDump) { + """ + [ + [0]: NaturalHeight( + rows: 6, + shortRows: 6, + tallRows: 6 + ), + [1]: NaturalHeight( + rows: 34, + shortRows: 500, + tallRows: 1000 + ), + [2]: NaturalHeight( + rows: 2, + shortRows: 500, + tallRows: 1000 + ) + ] + """ + } } } +@MainActor struct ScenarioCorpusTests { private static let scenarios = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .appendingPathComponent("Scenarios") - private func report(for relativePath: String) throws -> ScenarioReport { + private func report(for relativePath: String) async throws -> ScenarioReport { let url = Self.scenarios.appendingPathComponent(relativePath) let source = try String(contentsOf: url, encoding: .utf8) - let scenario = try ScenarioParser.parse(source, file: relativePath) - return ScenarioRunner.run(scenario, name: relativePath) + let scenario = try parse(source, file: relativePath) + return await ScenarioRunner.run( + scenario, name: relativePath, registry: Fixtures.registry()) } @Test(arguments: [ "workspace-desktop.scn", "workspace-input.scn", "workspace-compact.scn", "stack-mobile.scn", ]) - func `corpus scenarios pass and render identical bytes twice`(file: String) throws { - let first = try report(for: file) - let second = try report(for: file) + func `corpus scenarios pass and render identical bytes twice`(file: String) async throws + { + let first = try await report(for: file) + let second = try await report(for: file) expectNoDifference(first.failures, []) #expect(first.passed) @@ -334,8 +456,8 @@ struct ScenarioCorpusTests { } @Test - func `the failing scenario fails four expectations`() throws { - let report = try report(for: "failing/mismatch.scn") + func `the failing scenario fails four expectations`() async throws { + let report = try await report(for: "failing/mismatch.scn") assertInlineSnapshot(of: report.human(), as: .lines) { """ @@ -376,7 +498,7 @@ struct ScenarioCorpusTests { line 10: expect node type=Missing frame 0,0,1x1 locator `type=Missing` matches no node expected: 0,0,1x1 - actual: (no node; types: VStack, Text, Divider, HStack, Spacer) + actual: (no node; types: TupleView, VStack, Text, Divider, HStack, Spacer) screen: 0| Stack demo 1| ──────────────────────────────────────── diff --git a/Tests/TesseraRuntimeTests/StepTests.swift b/Tests/TesseraRuntimeTests/StepTests.swift new file mode 100644 index 00000000..444676a4 --- /dev/null +++ b/Tests/TesseraRuntimeTests/StepTests.swift @@ -0,0 +1,120 @@ +import Tessera +import Testing + +/// A nonisolated application that reports what the graph looked like when it applied an event. +private final class RecordingApp: Application { + private(set) var count = 9 + private(set) var applied: [InputEvent] = [] + var onApply: ((InputEvent) -> Void)? + + var body: some View { + Text("Counter value is \(count)") + } + + func apply(_ event: InputEvent) { + applied.append(event) + onApply?(event) + if case .key(let key) = event, key.code == .character("+") { + count += 1 + } + } +} + +/// A main-actor application with an isolated conformance. +@MainActor +private final class MainActorApp: @MainActor Application { + private(set) var count = 9 + + var body: some View { + Text("Counter value is \(count)") + } + + func apply(_ event: InputEvent) { + if case .key(let key) = event, key.code == .character("+") { + count += 1 + } + } +} + +/// A host on an actor of its own, passing `self` as the isolation of the step. +private actor ActorHost { + func stepPlus() -> Int { + let app = RecordingApp() + let graph = ViewGraph(root: { app.body }, size: TerminalSize(columns: 40, rows: 4)) + Step.run(app, graph: graph, event: plus, isolation: self) + return app.count + } +} + +private let plus = InputEvent.key(Key(code: .character("+"))) + +private func makeGraph(for app: App) -> ViewGraph { + ViewGraph(root: { app.body }, size: TerminalSize(columns: 40, rows: 4)) +} + +private func rootColumns(of graph: ViewGraph) -> Int? { + graph.layoutIfNeeded() + return graph.diagnostics.nodes.first { $0.parentIdentity == nil }?.measuredSize?.columns +} + +@Test @MainActor +func `the step applies the event to the app and then updates the graph`() { + let app = RecordingApp() + let graph = makeGraph(for: app) + #expect(rootColumns(of: graph) == 18) + + let disposition = Step.run(app, graph: graph, event: plus, isolation: MainActor.shared) + + #expect(app.applied == [plus]) + #expect(app.count == 10) + #expect(rootColumns(of: graph) == 19) + #expect(disposition == .ignored) +} + +@Test @MainActor +func `a resize resizes the graph before the app applies it`() { + let app = RecordingApp() + let graph = makeGraph(for: app) + _ = rootColumns(of: graph) + #expect(graph.needsLayout == false) + var layoutPendingWhenApplied: [Bool] = [] + app.onApply = { _ in layoutPendingWhenApplied.append(graph.needsLayout) } + + Step.run( + app, graph: graph, event: .resize(TerminalSize(columns: 20, rows: 3)), + isolation: MainActor.shared) + + #expect(layoutPendingWhenApplied == [true]) + #expect(app.applied == [.resize(TerminalSize(columns: 20, rows: 3))]) +} + +@Test @MainActor +func `a resize that changes nothing still reaches the app`() { + let app = RecordingApp() + let graph = makeGraph(for: app) + let unchanged = InputEvent.resize(TerminalSize(columns: 40, rows: 4)) + + Step.run(app, graph: graph, event: unchanged, isolation: MainActor.shared) + + #expect(app.applied == [unchanged]) +} + +@Test @MainActor +func `a main-actor app steps through the same step on the main actor`() { + let app = MainActorApp() + let graph = makeGraph(for: app) + + Step.run(app, graph: graph, event: plus, isolation: MainActor.shared) + + #expect(app.count == 10) + #expect(rootColumns(of: graph) == 19) +} + +@Test +func `a nonisolated app steps on an actor through the same step`() async { + let host = ActorHost() + + let count = await host.stepPlus() + + #expect(count == 10) +} diff --git a/docs/architecture/developer-tooling.md b/docs/architecture/developer-tooling.md index 2087805d..d2bd7f60 100644 --- a/docs/architecture/developer-tooling.md +++ b/docs/architecture/developer-tooling.md @@ -5,7 +5,7 @@ description: What Tessera's developer tools are, how they fit together, and the # Developer tooling -This page describes Tessera's developer tools: what each one is, how they fit together, and the rules they follow. They serve two audiences: contributors building Tessera, and authors of apps built on Tessera, who drive and check their own apps with the same harness, scenarios, and checks. Each tool's status in [The tools](#the-tools) says whether it exists yet or is only designed. The how-to for using them is the `tessera-devtools` skill (and, later, a user guide); this page is the design they implement. +This page describes Tessera's developer tools: what each one is, how they fit together, and the rules they follow. They serve two audiences: contributors building Tessera, and authors of apps built on Tessera, who drive and check their own apps with the same harness, scenarios, and checks. Each tool's status in [The tools](#the-tools) says whether it exists yet or is only designed. The how-to for using them is the `tessera` skill for app authors and `tessera-contributing` for contributors (and, later, a user guide); this page is the design they implement. ## Why the tools exist @@ -50,11 +50,11 @@ Read the diagram as one scenario run. `tessera run` and `swift test` are two way | Tool | Who uses it | Where it lives | Status | | --- | --- | --- | --- | -| Demos and the registry | both | demos in `Examples/Sources/DemoSupport`, `ApplicationRegistry` in `TesseraHarness` | designed | +| Demos and the registry | both | demos in `Examples/Sources/DemoSupport`, `ApplicationRegistry` in `TesseraHarness` | built; one demo so far, the counter | | The app contract (`Application`) | both | `TesseraRuntime`, re-exported by `Tessera` | built | -| Headless driver (`Driver`) | both | `TesseraHarness` | designed; the spike's driver is ported as `package` code | -| Scenario language and runner | both | grammar in `Examples/Scenarios/README.md`, runner in `TesseraHarness` | designed; the spike's v0 parser, runner, and report are ported as `package` code | -| `tessera` CLI and `mise run dev:*` | agents first | `Examples/Sources/TesseraDev`, `mise-tasks/dev/` | designed | +| Headless driver (`Driver`) | both | `TesseraHarness` | built: one `Driver` under a headless host, a live host, and a per-test actor host, all internal to `TesseraHarness` (tests reach them with `@testable import`) | +| Scenario language and runner | both | grammar in `Examples/Scenarios/README.md`, runner in `TesseraHarness` | designed; the spike's v0 parser, runner, and report are ported as `package` code and run over the registry | +| `tessera` CLI and `mise run dev:*` | agents first | `Examples/Sources/TesseraDev`, `HarnessCLI` in `TesseraHarness`, `mise-tasks/dev/` | `apps`, `launch`, and `help` built; the other commands designed | | Projection and locators | agents first | `TesseraHarness` | designed | | Invariant checks (`check`) | both | `TesseraHarness` | designed | | Shots (text, grid, SVG) | both | goldens beside the scenario; pictures in `.build/tessera/` | designed | @@ -64,7 +64,8 @@ Read the diagram as one scenario run. `tessera run` and `swift test` are two way | Pointer verbs and hit query | both | scenario `mouse`, `hit` | designed | | Schedulable clock | both | environment `Clock` plus scenario `wait`, `settle` | designed | | Live inspection socket | agents | `TESSERA_DEBUG_SOCKET`, `tessera attach` | designed | -| `tessera-devtools` skill | agents | `.agents/skills/tessera-devtools/` | designed | +| `tessera` skill | agents of app authors | `.agents/skills/tessera/` | built; covers each tool built above | +| `tessera-contributing` skill | agents of contributors | `.agents/skills/tessera-contributing/` | built; covers the `mise` tasks and the demos, over the `tessera` skill | ### Demos @@ -79,6 +80,8 @@ A demo has a registry name (kebab-case), a minimum size from its catalog doc, an The registry (`ApplicationRegistry`) is the one place apps, Tessera's demos among them, are selected; it fails closed on a duplicate name, and its factories are main-actor closures. `tessera launch NAME` runs an app live in your terminal; the same demo renders identically headless, so its renders are snapshot fixtures, and its snapshot test is a few lines. An intro app composes the per-component demos with navigation only; it adds no component behaviour. "Demo" is Tessera's word for its own per-component example apps; the CLI and the scenario header say `app`. +Built today: `Demo` in `Examples/Sources/DemoSupport` is an `Application` that also declares its registry name, minimum size, viewports, and fixtures (further registrations of the same demo), none of them defaulted, and `Demos.registry()` registers every demo and fixture into one `ApplicationRegistry`. A demo quits on `q`, matched by `isQuitKey`, which compares the key's code, modifiers, and kind and never `Key` equality, so an enriched Kitty key still quits and a release or a repeat does not. The first demo is the counter. Registering a name twice is not an error where it is written; it makes every read of the registry fail (exit 3 from a command, a failing report from a scenario), so a later registration never replaces an earlier one. A demo's snapshot test is a few lines in `Examples/Tests/DemoSupportTests`: it opens the demo headless at a viewport, sends keys, and asserts rows of the screen. + ### The headless driver `TesseraHarness` is a library in the root package, beside `TesseraTestSupport`. It lives there because it needs `package` seams that a separate package cannot see (`InMemoryTerminalDevice.deliverResize`, `TerminalSession.lastDrawn`, the output tap). App authors import it from their test and dev targets to drive and check their own apps, so its public names are chosen for them and are the ones [Stability](#stability) promises; libghostty-vt reaches apps as a prebuilt binary (see [libghostty-vt distribution](libghostty-vt-distribution.md)). @@ -87,6 +90,8 @@ The harness drives anything that adopts `Application`, declared in `TesseraRunti `Driver` is a `@MainActor` shell over one nonisolated step that takes the host's isolation as an `isolated any Actor` parameter; the Phase 5 runtime is a second shell over the same step. `Driver.step(event)` runs the canonical step per input: resize the graph if the event is a resize, then `graph.dispatch`, then the app applies the event if the graph left it `.ignored` (a resize always reaches the app, because it is never routed), then `graph.update()`; then `TerminalSession.draw` runs its body in the driver's isolation and commits the frame into one persistent virtual terminal. The live runner is the same `Driver` over the real terminal device, so live and headless cannot drift; and because headless runs take the live drawing path, the session's colour, underline, and hyperlink policy and its differential rendering are exercised, not bypassed. The step is synchronous: when it returns, `apply` has run, the graph is updated, and the screen is rendered, so a scenario statement is one step and nothing sleeps. The full contract is the spec's [Runtime isolation](../spec/phase-5-runtime-and-polish.md#runtime-isolation) section. +Built today: the driver delivers its starting size to the app as a `.resize` through the same step, before the first frame, because an app has no other way to learn the size it starts at. The headless host runs a session over a device of the harness's own (`HeadlessTerminalDevice`) and feeds every frame from the session's output tap to one persistent Ghostty virtual terminal; the harness does not use `InMemoryTerminalDevice` because that device lives in a module that links the snapshot-testing library, and an executable that links it cannot launch. The live host opens the real terminal and hands the session's scope a main-actor function value, so the app's conformance never leaves the main actor. Tessera's nonisolated demos can also be stepped on a per-test actor, which keeps parallel suites parallel. `mobile` is 40 columns at the height the app's content needs, which the harness measures with a package-level nil-height proposal on `ViewGraph` and confirms by measuring at two tall heights: an app whose root grows with the proposal has no natural height, and `size mobile` fails naming `mobileScreen`. Isolation is pinned by two compile-fail fixtures, each with a positive control, that `mise run quality:architecture` runs: an actor host given a main-actor app, and a default-main-actor type adopted from another module without `@MainActor` on the conformance. + ### Scenarios A scenario is a text file under `Examples/Scenarios/`: one statement per line, shell quoting, `#` comments. A header binds the app and the terminal; the body sends input, observes, and asserts. @@ -170,6 +175,12 @@ The driver writes one JSONL record per step: sequence number, event, the pass's Exit codes: 0 pass, 1 an expectation or check failed, 2 a usage or parse error, 3 a harness error. When stdout is not a terminal, each command writes one JSON object to stdout; `help COMMAND` prints real text. `mise run dev:run`, `dev:shot`, and the rest mirror the commands. CI runs `swift test --package-path Examples` and `tessera run Examples/Scenarios` on macOS, Linux, and Windows; goldens are checked out with LF line endings everywhere, so a Windows checkout cannot drift them. +Built today: `apps`, `launch NAME`, and `help [COMMAND]`, with `--json` and `--pretty` forcing either form of `apps`. `apps` prints a name, minimum, and viewports table on a terminal, and otherwise one sorted-key JSON object, `{"apps": [{"minimum", "name", "viewports"}], "command": "apps"}`, where `minimum` is `null` for an app that declares none. `launch` refuses to start unless standard input and output are both terminals (exit 3, before the terminal changes mode), because it opens a live session; an unknown name is a usage error (exit 2) that lists the registered names. `HarnessCLI.main(registry:)` is the entry point an app author's executable calls; `Examples/Sources/TesseraDev` is Tessera's, over `Demos.registry()`, and its product is named `tessera` while its module is `TesseraDev`, so the executable and the `Tessera` library share no artifact name on a case-insensitive file system. + +The commands and flags are declared with [swift-argument-parser](https://github.com/apple/swift-argument-parser), and only `TesseraHarness` depends on it (`scripts/check-package-boundaries.py` allowlists the one product), so the library, the runtime, and the terminal targets stay free of it. ArgumentParser parses the arguments and writes the help; `HarnessCLI` keeps the promises it would not: a usage error exits 2 where ArgumentParser exits 64, and the JSON or text form is read from the raw `--json` and `--pretty` before parsing, so a parse that fails is still reported in the form the caller asked for. A usage error says what is accepted where it happened: the commands for an unknown command, the app names for an unknown app, and the command's usage for a bad flag or argument. The executable prints a completion script for fish, zsh, and bash with `--generate-completion-script SHELL`, and `launch` completes the registered names by asking the executable at completion time, so no list can fall out of date. `HarnessCLI.main(registry:)` stays the only entry point, and no command depends on how the executable was found, so a prebuilt front end or a SwiftPM plugin can forward to an app's executable unchanged. + +Each `dev:*` task takes its app name as an optional argument. Run without one, it prints the command's help and the registered apps, where mise would report a missing argument. At a terminal that is the answer and the task exits 0; in a pipe or a script nothing ran, so the task exits 2, as `tessera` does, and an unset variable cannot pass for success. A `dev:*` task builds `tessera` once, quietly, and runs the binary it built, so a build that succeeds prints nothing and one that fails still prints its errors. `mise run dev:launch ` completes the app names, from the `Examples/.build/debug/tessera` an earlier run built; completion never builds, so it offers nothing until there is one. + ### Stability Tessera 1.0 promises a small surface to app authors and their CI, and leaves everything else free to change. The public Swift names are `Application` (in `TesseraRuntime`, re-exported by `Tessera`) and, in `TesseraHarness`, `ApplicationRegistry`, `Viewport`, `HarnessCLI` (`main(registry:)`), `ScenarioRunner`, `ScenarioReport`, and `ScenarioFailure`. Everything else in the harness is `package` or internal, and there is no public stepping API: app authors drive an app through scenarios, run from `swift test` by `ScenarioRunner` or from their dev executable by `HarnessCLI`. Within a major version these stay as they are: @@ -195,14 +206,16 @@ The gallery (`DevTools/gallery/`, served by `scripts/gallery-serve.py`) shows ea ## The development loop -The loop the `tessera-devtools` skill teaches, for humans and agents alike: +The loop the `tessera` skill teaches app authors, for humans and agents alike: -1. `tessera launch NAME` to see the component. -2. `tessera run Examples/Scenarios/NAME.scenario` and read the report (or its JSON). +1. `tessera launch NAME` to see the app. +2. `tessera run PATH` on the app's scenario, and read the report (or its JSON). 3. `shot` to picture the failing state, `tree` to see the nodes and frames. 4. Read the trace to learn why a redraw or a focus move happened. 5. `check` to find the broken invariant. -6. Fix the library, never the demo, and rerun. +6. Fix the app and rerun; when the fault is Tessera's, report a Tessera bug and do not work around it. + +The `tessera-contributing` skill runs the same loop over Tessera's own demos: through `mise run dev:*`, on a scenario from `Examples/Scenarios`, and with step 6 as fix the library, add a regression test, and never bend the demo. ## Open questions @@ -210,7 +223,7 @@ The loop the `tessera-devtools` skill teaches, for humans and agents alike: ## Keeping this page current -This page is the index for the tools. A change that adds or changes a tool updates the matching section here, and the `tessera-devtools` skill, in the same change: what the tool is now, what it does not do yet, and its status in [The tools](#the-tools). When a tool grows past a section, its detail moves to its own page under `docs/architecture/developer-tooling/`, and this page keeps a summary and a link. How-tos belong in the skill and the user guide, not here. +This page is the index for the tools. A change that adds or changes a tool updates the matching section here and the skills, in the same change: the `tessera` skill first, as an app author sees the tool, then `tessera-contributing` for what only a contributor has. The page says what the tool is now, what it does not do yet, and its status in [The tools](#the-tools); the skills describe only what exists. When a tool grows past a section, its detail moves to its own page under `docs/architecture/developer-tooling/`, and this page keeps a summary and a link. How-tos belong in the skill and the user guide, not here. ## Affected plans diff --git a/docs/plans/phase-4-view-layer-and-developer-tooling.md b/docs/plans/phase-4-view-layer-and-developer-tooling.md index 3e59819b..fd0cbfa2 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -8,7 +8,7 @@ description: Build the Phase 4 view layer from the omp2-reviewed catalog and the # Build the Phase 4 view layer with the tooling that proves it -Goal: Tessera's 1.0 view layer (styling, focus, pointer, scrolling, controls, collections, SplitView, the remaining views, and an intro app) lands component by component from the `ready` catalog docs, and every component arrives with a demo that runs live and headlessly, a scenario that an agent can run without prose, and the harness checks that catch the defect classes the archived attempts missed. When this is done, `mise run dev:run Examples/Scenarios` passes on macOS and Linux, `conform` gates every `implemented` catalog page, and a fresh agent given only the `tessera-devtools` skill can drive a demo, read a failure, and take a shot. +Goal: Tessera's 1.0 view layer (styling, focus, pointer, scrolling, controls, collections, SplitView, the remaining views, and an intro app) lands component by component from the `ready` catalog docs, and every component arrives with a demo that runs live and headlessly, a scenario that an agent can run without prose, and the harness checks that catch the defect classes the archived attempts missed. When this is done, `mise run dev:run Examples/Scenarios` passes on macOS and Linux, `conform` gates every `implemented` catalog page, and a fresh agent given only the `tessera` skill can drive an app, read a failure, and take a shot. This plan merges plan 032 (Phases 8-17, its open implementation phases; Phases 1-7 are complete and stay there as history) and plan 033 (the developer tooling, with its spikes, decisions, and owner feedback). Both are cancelled and link here (plan 035 Step 6.3). @@ -17,7 +17,7 @@ This plan merges plan 032 (Phases 8-17, its open implementation phases; Phases 1 - [ ] 1 Terminal colour reports - [x] 2 Seams for the harness: seams `ea513af`, caller-isolated `draw` `0bb02e7`, and the commit-time open check from the critic review on `phase-4/02-seams` (PR #59); `mise run core:test -- --filter TerminalSessionTests` runs 106 tests green (94 before; 9 seam tests, a `@MainActor` draw test, an `isolated TerminalSession` draw test, and an in-flight-draw-across-close test added) and the two `ViewGraphClockTests` are green; full `mise run core:test` (676 tests), `quality:format`, `quality:lint`, and `docs:lint` green on Xcode 26.6; the full suite and `swift build --package-path Examples` green on Xcode 27.1, and the Examples `isolated TerminalSession` loops build unchanged on both toolchains; the added lines declare no `public` API beyond `draw`'s relaxed signature and the existing `ViewGraph` init gaining `convenience`; CI runs 40027319517 (seams) and 40057464257 (`draw`, merged with `main`) green on every leg, and the head's run is on the PR. - [x] 3 Harness target, `TesseraRuntime`, and Showcase retirement: runtime and harness `38cc7fd`, Showcase retirement `53b7a25` on `phase-4/03-harness-runtime` (PR #64, stacked on #59 and merged with its head `08174d2`); full `mise run core:test` runs 700 tests green on Xcode 26.6 (676 on the base; 6 `TesseraRuntimeTests` adoption-shape tests and the spike's 18 tests, now `TesseraHarnessTests`, added), and `mise run quality:format`, `quality:lint` (its `quality:architecture` runs the boundary checker's 10 unit tests, one added, over the new `TesseraRuntime` and `TesseraHarness` rows), and `docs:lint` are green; `swift build` and `swift test --package-path Examples` are green on Xcode 26.6 (13 tests, `Examples/Package.resolved` unchanged), and the full suite (700 tests) and the Examples build are green on Xcode 27.1; no `public` declaration remains in `Sources/TesseraHarness`, nothing under `Sources`, `Tests`, `Examples`, `mise-tasks`, `scripts`, or `.github` mentions the Showcase, and `git diff --name-only origin/phase-4/02-seams...HEAD` lists no `Package.resolved`; `spikes/README.md` says nothing new goes into `spikes/`; CI run 40059692549 (`53b7a25`) and run 40060342201 (the merged head `e05d6e9`) are green on every leg, Windows and the static Linux SDK included; the critic-review fixes (the isolation wording in `Application`'s docs and the spec, `Examples/Sources/ApplicationConsumer`, and the removal of the harness's unused `CLI`) follow on the same branch, and the PR carries the head's run. -- [ ] 4 Registry, viewports, the main-actor driver, and `tessera launch` +- [x] 4 Registry, viewports, the main-actor driver, and `tessera launch`: `338bd6f` on `phase-4/04-driver` (PR #65, stacked on #64); full `mise run core:test` runs 750 tests green on Xcode 26.6 and on Xcode 27.1 (700 on the base; the registry, driver, host, CLI, and viewport tests in `TesseraHarnessTests`, five step tests in `TesseraRuntimeTests`, and three `measuredRootSize` tests in `TesseraCoreTests` added), `swift test --package-path Examples` runs 23 tests on both toolchains (13 on the base; the counter demo's snapshot tests and the quit-key tests added), and `mise run quality:format`, `quality:lint` (its `quality:architecture` runs the boundary checker's 10 unit tests over the widened `TesseraHarness` row and both isolation probes, each with its control; a wrong expectation fails the script), and `docs:lint` are green; neither `Package.resolved` changed. `tessera launch counter` and `mise run dev:launch counter` ran live under a pty (`expect`, 80x24): the alternate screen opened, `+ + + -` drew counts 1, 2, 3, 2, `q` drew a last frame, every mode was restored, and the exit status was 0. `DriverTests` renders a `ViewGraph` through `TerminalSession.draw` into an `InMemoryTerminalDevice` and pins the bytes, which restores the retired Showcase's `ShowcaseTerminalSessionTests` coverage. The `tessera` executable and the `Tessera` library coexist on case-insensitive APFS: the build trees hold no two names that differ only by case. CI on `e707aa0` (run 40067583854) passed all 14 jobs, including `test (windows-latest)`, which compiles the WinSDK branch of the stream shim and runs the harness tests, and `test (xcode-27)`; the six nits of the critic review of PR #65 are addressed on top of it, and CI now also runs the Examples tests. - [ ] 5 Scenario language, runner, the `tessera` CLI, and the CI corpus - [ ] 6 Projection, locators, invariants, cost, trace, and shots, with the first demos - [ ] 7 Flow layout and `ViewThatFits` @@ -35,7 +35,7 @@ This plan merges plan 032 (Phases 8-17, its open implementation phases; Phases 1 - [ ] 19 Docs, skill, and the conformance gate - [ ] 20 Intro app and Phase 4 closure -Next: Slice 1 in its own pull request. Slices 2 and 3 are done (PR #59: the harness seams and the caller-isolated `TerminalSession.draw`; PR #64, stacked on #59: `TesseraRuntime`, `TesseraHarness`, and the Showcase retirement). Slice 4 follows Slice 3 and can branch from `phase-4/03-harness-runtime` until #59 and #64 merge. Decision 9 (i) is recorded below, so nothing in Slices 4-7 waits on the owner; the owner approved the Slice 3 decisions that were marked "(owner to weigh)" on 2026-10-24, so nothing blocks Slice 4. +Next: Slice 1 in its own pull request. Slices 2, 3, and 4 are done (PR #59: the harness seams and the caller-isolated `TerminalSession.draw`; PR #64, stacked on #59: `TesseraRuntime`, `TesseraHarness`, and the Showcase retirement; PR #65, stacked on #64: the registry, viewports, the main-actor driver, and `tessera launch`). Slice 5 follows Slice 4 and can branch from `phase-4/04-driver` until #59, #64, and #65 merge. Decision 9 (i) is recorded below, so nothing in Slices 5-7 waits on the owner; the owner approved the Slice 3 decisions that were marked "(owner to weigh)" on 2026-10-24, and the Slice 4 decisions marked "(owner to weigh)" are reversible; none of them block Slice 5. ## Approach @@ -43,7 +43,7 @@ Current state. `main` carries Phase 4 Slices 1, 2, and 2.5 (`TesseraCore`, `Tess End state. The components of `docs/spec/phase-4/` "The 1.0 surface" are implemented and graduated into DocC, each demo is a snapshot fixture at its declared viewports, `Examples/Scenarios/` is the behaviour corpus that CI runs on macOS, Linux, and Windows, and `TesseraRuntime` (the `Application` contract, re-exported by `Tessera`), `TesseraHarness` (root package; the library app authors import to drive and check their own apps), `Examples/Sources/DemoSupport`, and `Examples/Sources/TesseraDev` (the target of the `tessera` executable) are the tooling that plan 033 designed. -Patterns to follow. Each step starts from a `ready` catalog doc (finishing it is the first part of the step). Each component lands with its tests, its demo, and its scenario in one reviewable step; port from the archive tags (`archive/phase4`, `archive/phase4-review-loop`, `archive/phase4-unicode-demo-stash`, `archive/catalog-workbench`) only after reading the code against the doc, using the port map in `docs/research/phase-4-salvage-review.md`. Measurement and `body` stay pure; every `NodeState` row has one owner; collections key by stable IDs and fail closed on duplicates. The harness never compensates for a library bug (demos find bugs; the library is fixed). Text goldens are the only committed artefacts; drift fails without `--record`. No test affordances in product API: `TesseraCore` gains only value-typed diagnostics fields, each by spec amendment. A slice that adds or changes a tool updates [Developer tooling](../architecture/developer-tooling.md) (or the per-tool page it links) and the `tessera-devtools` skill before the slice is checked off, so the page describes the tools as built plus what is still only designed, and agents building later slices use the skill as they go. The app contract is Decision 9 (i) and the spec's [Runtime isolation](../spec/phase-5-runtime-and-polish.md#runtime-isolation) section: `Application` carries no isolation, a host is a `@MainActor` shell over one nonisolated step that takes `isolated any Actor`, and isolation mistakes are proved by compile-fail fixtures, never by `assumeIsolated`. The harness's public names are the seven the stable-surface decision lists; every other declaration in it is `package` or internal. "Demo" is the word for Tessera's own per-component example apps; the CLI and the scenario header say `app`. +Patterns to follow. Each step starts from a `ready` catalog doc (finishing it is the first part of the step). Each component lands with its tests, its demo, and its scenario in one reviewable step; port from the archive tags (`archive/phase4`, `archive/phase4-review-loop`, `archive/phase4-unicode-demo-stash`, `archive/catalog-workbench`) only after reading the code against the doc, using the port map in `docs/research/phase-4-salvage-review.md`. Measurement and `body` stay pure; every `NodeState` row has one owner; collections key by stable IDs and fail closed on duplicates. The harness never compensates for a library bug (demos find bugs; the library is fixed). Text goldens are the only committed artefacts; drift fails without `--record`. No test affordances in product API: `TesseraCore` gains only value-typed diagnostics fields, each by spec amendment. A slice that adds or changes a tool updates [Developer tooling](../architecture/developer-tooling.md) (or the per-tool page it links) and the skills before the slice is checked off, the `tessera` skill first, written as an app author sees the tool against the stable surface, then `tessera-contributing` for what only a contributor has (the `mise` tasks, the demos), so the page describes the tools as built plus what is still only designed, and agents building later slices use the skills as they go. The app contract is Decision 9 (i) and the spec's [Runtime isolation](../spec/phase-5-runtime-and-polish.md#runtime-isolation) section: `Application` carries no isolation, a host is a `@MainActor` shell over one nonisolated step that takes `isolated any Actor`, and isolation mistakes are proved by compile-fail fixtures, never by `assumeIsolated`. The harness's public names are the seven the stable-surface decision lists; every other declaration in it is `package` or internal. "Demo" is the word for Tessera's own per-component example apps; the CLI and the scenario header say `app`. Patterns to avoid. The Showcase (a monolithic demo app) and the archived batch attempts; porting review bundles or committed captures; a `wait` that sleeps; prints as the way to answer "why". @@ -94,7 +94,7 @@ Plan 032 Step 8.2 and plan 033 Step B.1, under Decision 9 (i) and the spec's [Ru `TesseraHarness` gains two public names. `ApplicationRegistry` has `init()` and `mutating func register(_ name: String, minimum: TerminalSize? = nil, viewports: [Viewport], make: @escaping @MainActor () -> App)`. It is the one selection point; the harness opens factories on the main actor, once per run, so each run starts fresh and a fixture is one more registration (`register("browser-empty") { Browser(fs: .empty) }`). A duplicate name makes every entry point that reads the registry fail (exit 3, or a failing report), never last-wins; registration itself does not throw (the spike's did; do not copy it). A `@Sendable` factory for a main-actor app is a compile error at the registration site. `Viewport` is a `Hashable, Sendable` enum: `desktop` (80x24), `mobile`, `mobileScreen` (40x16), `custom(TerminalSize)`. -Everything else is `package`. `TesseraRuntime` gains the canonical step, a nonisolated `package` function that takes the host's isolation as a parameter, written as the spec's [canonical step](../spec/phase-5-runtime-and-polish.md#the-canonical-step) has it (`Step.run(_ app:graph:event:isolation: isolated any Actor)`, returning the `EventDisposition`): resize the graph if the event is a `.resize`, `graph.dispatch`, `app.apply(event)` when the disposition is `.ignored` or the event is a resize (a resize is never routed, so Slice 9's routing cannot take it from apps), then `graph.update()`. There is no `#isolation` default, so a nonisolated caller cannot compile the call. `TesseraHarness` gains the `package` `@MainActor` `Driver`, a shell over that step: it owns the app (built by the registry factory), the `ViewGraph` and its base environment, and a `TerminalSession`; `step(_:)` calls `Step.run(…, isolation: MainActor.shared)` and then `session.draw` when `graph.needsRender`, and the host stops after the step that sets `isFinished`. The step order and update policy are ported from the review loop's `ApplicationDriver`, without its `isolated TerminalSession` hosting and its hard-coded `q`. `HeadlessHost` (a session over `InMemoryTerminalDevice` into one persistent Ghostty virtual terminal) and `LiveHost` (a session over the real device inside `withApplicationTerminal`, handing its scope a main-actor function value rather than the generic driver, as the spec's runtime section explains) share the `Driver` and the `draw` path. The harness has no quit key and no Tab policy. Tessera's own nonisolated demos may additionally be stepped on per-test actors, one actor per test (a `package` shell over the same `Step.run` with `isolation: self`), which keeps parallel suites parallel; that shell is not public. +Everything else is `package` or internal (the step and the registry's reads are `package`; the driver and its hosts are internal, because nothing outside the harness needs them and tests reach them with `@testable import`). `TesseraRuntime` gains the canonical step, a nonisolated `package` function that takes the host's isolation as a parameter, written as the spec's [canonical step](../spec/phase-5-runtime-and-polish.md#the-canonical-step) has it (`Step.run(_ app:graph:event:isolation: isolated any Actor)`, returning the `EventDisposition`): resize the graph if the event is a `.resize`, `graph.dispatch`, `app.apply(event)` when the disposition is `.ignored` or the event is a resize (a resize is never routed, so Slice 9's routing cannot take it from apps), then `graph.update()`. There is no `#isolation` default, so a caller must name an isolation: a synchronous nonisolated caller cannot compile the call, and an async caller that passes another actor hops to it. `TesseraHarness` gains the internal `@MainActor` `Driver`, a shell over that step: it owns the app (built by the registry factory), the `ViewGraph` and its base environment, and a `TerminalSession`; `step(_:)` calls `Step.run(…, isolation: MainActor.shared)` and then `session.draw` when `graph.needsRender`, and the host stops after the step that sets `isFinished`. The step order and update policy are ported from the review loop's `ApplicationDriver`, without its `isolated TerminalSession` hosting and its hard-coded `q`. `HeadlessHost` (a session over `InMemoryTerminalDevice` into one persistent Ghostty virtual terminal) and `LiveHost` (a session over the real device inside `withApplicationTerminal`, handing its scope a main-actor function value rather than the generic driver, as the spec's runtime section explains) share the `Driver` and the `draw` path. The harness has no quit key and no Tab policy. Tessera's own nonisolated demos may additionally be stepped on per-test actors, one actor per test (an internal shell over the same `Step.run` with `isolation: self`), which keeps parallel suites parallel; that shell is not public. `Examples/Sources/DemoSupport`: `Demo` is an `Application` that declares its registry name, minimum size, `viewports`, and `fixtures` (further registrations of the same demo), no defaults; `DemoSupport` registers every demo into one `ApplicationRegistry`. A demo handles its own quit key in `apply` (whole-key matching through a shared `isQuitKey` helper, never `Key` equality on enriched keys) and reports through `isFinished`. The first demo, a counter built from landed primitives, proves the path; Slice 6's Text demo may absorb it. The `mobile` rule is decided (Decision 1, (b)): `mobile` is 40 columns at natural height, derived through a package-level nil-height proposal on `ViewGraph` plus two-height detection, so a height-greedy demo fails `size mobile` with an error naming `mobileScreen` (40x16). Demos follow the rules in [Developer tooling](../architecture/developer-tooling.md#demos): interactive, every variable style shown, extra controls allowed for complex components, no workarounds for library bugs. @@ -102,7 +102,7 @@ Everything else is `package`. `TesseraRuntime` gains the canonical step, a nonis Isolation is pinned by two compile-fail fixtures beside `Tests/CompileFailFixtures/FrameRegionEscape.swift`, run by a script beside `scripts/check-frame-region-ownership.sh` and called from `mise-tasks/quality/architecture` the same way (`swiftc -typecheck` against the debug modules, an expected-diagnostic substring, and a positive control for each, so a toolchain that stops diagnosing fails the check instead of passing). First, an actor host given a main-actor app: `Browser: @MainActor Application` stepped from an actor through `Step.run(…, isolation: self)`; expect "main actor-isolated conformance of 'Browser' to 'Application' cannot be used in nonisolated context" (Swift 6.4 adds "cannot be used in actor-isolated context", so match the shared prefix); the control is the same app on a `@MainActor` host. Second, a default-main-actor type adopted from another module without `@MainActor` on the conformance: a module compiled with `-default-isolation MainActor` declares `Journal`, and a second module, typechecked in plain Swift 6 mode (no `InferIsolatedConformances`, no default isolation, which the repository's shared `swiftSettings` would otherwise add), writes `extension Journal: Application {}`; expect "crosses into main actor-isolated code and can cause data races"; the control is `extension Journal: @MainActor Application {}`. No exit tests are needed, because no path carries an `assumeIsolated`. -Create `.agents/skills/tessera-devtools/SKILL.md` with what this slice makes usable (`tessera apps`, `tessera launch NAME`, a demo's headless snapshot test) and an `AGENTS.md` pointer to it; later slices extend it. +Create the two skills: `.agents/skills/tessera/SKILL.md`, the published skill for app authors, with what this slice makes usable (the `Application`, `ApplicationRegistry`, and `HarnessCLI.main(registry:)` wiring, `tessera apps`, `tessera launch NAME`), and `.agents/skills/tessera-contributing/SKILL.md`, the in-repo skill for contributors, with the `mise run dev:*` tasks, the demos, and a demo's headless snapshot test; and an `AGENTS.md` pointer to both. Later slices extend the `tessera` skill first and the contributor skill after it. Delivers: one demo steps through one driver, headless and live: `tessera launch NAME` runs it in a terminal and a snapshot test renders it identically through the in-memory device. Verify: registry tests cover lookup and duplicates (every reading entry point fails); a greedy demo fails `size mobile`; one demo's snapshot test is a few lines, and it is the test that renders a `ViewGraph` through `TerminalSession.draw` into an `InMemoryTerminalDevice` and asserts the bytes the device received, which restores the coverage the retired Showcase's `ShowcaseTerminalSessionTests` gave; a nonisolated demo steps on a per-test actor and on the main actor through the same `Step.run`; both compile-fail fixtures fail with their expected diagnostics (the second one only in plain Swift 6 mode: Slice 3 checked it in a scratch two-module package on Swift 6.3.3 and 6.4, where the line is an error without `InferIsolatedConformances`, builds with default main-actor isolation in the extension's module, and builds with `InferIsolatedConformances` on 6.4 but still errors on 6.3.3 for a type isolated only through its module's default isolation), their controls compile, and `mise run quality:architecture` runs them; the `.resize` rule is in the step's doc comment and Slice 9's routing tests pin it once `dispatch` can consume events. `DemoSupport` re-adds the `Tessera` product to the Examples manifest and so replaces `Examples/Sources/ApplicationConsumer`, the one-file guard Slice 3 added for `import Tessera` from outside the root package; delete it here. @@ -192,9 +192,9 @@ Delivers: a demo in a real terminal answers `text`, `tree focused`, and `key Tab ### 19 Docs, skill, and the conformance gate -Plan 033 Phase H. The final review and revision of the `tessera-devtools` skill that Slice 4 created and each tooling slice extended (the loop: `tessera launch` β†’ `run` β†’ read JSON β†’ `shot` β†’ `tree` β†’ trace β†’ `check` β†’ fix the library, never the demo; never `--record` to make a failing run pass); `docs/guides/developer-tools.md` (the human how-to for Tessera contributors and app authors, linking [Developer tooling](../architecture/developer-tooling.md) for the design rather than repeating it); the app-author path from Decision 9 (an app declares `: Application` where the type is declared, or `extension MyApp: Application {}` (`: @MainActor Application` for a main-actor type) in a test or dev target; adds a dev executable whose body is `HarnessCLI.main(registry:)`; registers its apps with `@MainActor` factories; writes scenarios, runs them from `swift test` through `ScenarioRunner`, and runs `check` in its own CI; the guide shows the four adoption shapes of the spec's Runtime isolation section and says that an app's own hand-written loop must match the canonical step until it adopts `ApplicationRuntime`); [Developer tooling's Stability section](../architecture/developer-tooling.md#stability) checked against the shipped `Examples/Scenarios/README.md`, the CLI, and the report types; the spec's diagnostics paragraph checked against the amendments Slices 12 and 18 made; and the doc drift `docs/research/tui-devtools-prior-art.md` names fixed. `mise run ci:check` and CI run `conform` on `implemented` docs. +Plan 033 Phase H. The final review and revision of both skills that Slice 4 created and each tooling slice extended: the published `tessera` skill (the loop: `tessera launch` β†’ `run` β†’ read JSON β†’ `shot` β†’ `tree` β†’ trace β†’ `check` β†’ fix the app, or report a Tessera bug and never work around it; never `--record` to make a failing run pass) and the in-repo `tessera-contributing` skill (the `mise` tasks, the demos, and fix the library, never the demo); how the `tessera` skill is distributed is settled with Decision 9 (iii); `docs/guides/developer-tools.md` (the human how-to for Tessera contributors and app authors, linking [Developer tooling](../architecture/developer-tooling.md) for the design rather than repeating it); the app-author path from Decision 9 (an app declares `: Application` where the type is declared, or `extension MyApp: Application {}` (`: @MainActor Application` for a main-actor type) in a test or dev target; adds a dev executable whose body is `HarnessCLI.main(registry:)`; registers its apps with `@MainActor` factories; writes scenarios, runs them from `swift test` through `ScenarioRunner`, and runs `check` in its own CI; the guide shows the four adoption shapes of the spec's Runtime isolation section and says that an app's own hand-written loop must match the canonical step until it adopts `ApplicationRuntime`); [Developer tooling's Stability section](../architecture/developer-tooling.md#stability) checked against the shipped `Examples/Scenarios/README.md`, the CLI, and the report types; the spec's diagnostics paragraph checked against the amendments Slices 12 and 18 made; and the doc drift `docs/research/tui-devtools-prior-art.md` names fixed. `mise run ci:check` and CI run `conform` on `implemented` docs. -Delivers: a fresh agent given only the skill drives a demo, reads a failure, and produces a shot without reading source; an app outside this repository's package graph runs a scenario with `check` against a released or tagged Tessera. Verify: that agent run, recorded in this plan; the external app's CI run; CI fails on an unpinned `implemented` wireframe (probe and revert). +Delivers: a fresh agent given only the `tessera` skill drives an app, reads a failure, and produces a shot without reading source, with the `tessera-contributing` skill checked separately by a fresh agent that adds a demo and its snapshot test; an app outside this repository's package graph runs a scenario with `check` against a released or tagged Tessera. Verify: both agent runs, recorded in this plan; the external app's CI run; CI fails on an unpinned `implemented` wireframe (probe and revert). ### 20 Intro app and Phase 4 closure @@ -240,7 +240,7 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-21 Plan 032 Step 10.1 wires the dead `focusChanges` counter; the role and label hook lands before the first control; the environment clock is a `Clock` the harness can advance. (owner, plan 035 Step 6.1) - 2026-10-22 Flow and `ViewThatFits` (plan 032 Step 14.1) move before the controls, after focus and hit testing. (this plan, from the 2026-10-21 owner review note under plan 032 Step 13.2) Superseded 2026-10-23 by Decision 2 below. - 2026-10-22 Decision 1, the `mobile` viewport: (b), 40 columns at the component's natural height, with `mobileScreen` (40x16) declared per demo for screen-filling compositions; the harness derives natural height with the package nil-height probe and two-height detection, and a height-greedy demo fails `size mobile` naming `mobileScreen`. This confirms the 2026-10-10 catalog decision; `docs/design/templates/widget.md`, `docs/design/widgets/stepper.md`, and Slice 4 were brought into line, and plan 032 Step 8.2 stays as history in the cancelled plan. (owner, plan 035 Step 6.2) -- 2026-10-22 The tools' design lives in `docs/architecture/developer-tooling.md`, written before Slice 2 from plan 033 and the spikes, without plan or slice references; each slice that touches a tool keeps it and the `tessera-devtools` skill current before the slice is checked off, and a tool that outgrows its section moves to its own page under `docs/architecture/developer-tooling/`. The skill is created in Slice 4 so the agents building later slices use it, and Slice 19 is its final review. Demos show their component working (interactive, every variable style, extra controls allowed for complex components) rather than the archived "teach rather than dump" bar. (owner) +- 2026-10-22 The tools' design lives in `docs/architecture/developer-tooling.md`, written before Slice 2 from plan 033 and the spikes, without plan or slice references; each slice that touches a tool keeps it and the `tessera-devtools` skill current before the slice is checked off, and a tool that outgrows its section moves to its own page under `docs/architecture/developer-tooling/`. The skill is created in Slice 4 so the agents building later slices use it, and Slice 19 is its final review. Superseded 2026-10-24 by the skill split below. Demos show their component working (interactive, every variable style, extra controls allowed for complex components) rather than the archived "teach rather than dump" bar. (owner) - 2026-10-22 Decision 3, live inspection: (b). Amend the spec so a dev runner outside `TesseraCore` may expose an opt-in, local, same-user endpoint that serializes the harness projection and injects input through the normal step path; `TesseraCore` still serializes nothing. Slice 18 is no longer optional. (owner) - 2026-10-22 Decision 4, `role` and `label`: (b) with (a) as the fallback. Library controls set optional, value-typed fields through an internal hook (spec amendment in Slice 12); nodes without them derive role from `type` and label from screen text. No public label modifier in 1.0; the fields are shaped for a later accessibility or Tern Surface Protocol export, which the owner wants eventually so a Tessera app can render natively in Tern or a similar host. (owner) - 2026-10-22 Decision 7, Windows: the scenario corpus is required on Windows from Slice 5, with LF goldens through `.gitattributes`. If a Windows toolchain bug ever blocks it, that leg drops to non-blocking for that case and the reason is recorded here. (owner) @@ -264,7 +264,19 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-24 Source changes in the moved code beyond access and imports, none of which change behaviour: the formatter's reflow and `Self` in static references; member reordering that SwiftLint's `type_contents_order` requires (`DemoApp`, the two fixtures' initializers, `Locator`'s nested types and properties, `Driver`'s initializer); sorted `Viewport` cases (`sorted_enum_cases`); braces around `Locator.resolve`'s early returns; and targeted disables for `discouraged_optional_collection` (`ScenarioFailure.screen`, where nil is omitted from the JSON) and `optional_data_string_conversion` (`ScenarioReport.json`'s lossy UTF-8 decode). (this plan, Slice 3 run) - 2026-10-24 `Application` declares `isFinished` before `apply(_:)`, the reverse of the spec's snippet, because SwiftLint's `type_contents_order` wants properties before methods even in a protocol; the order carries no meaning. (this plan, Slice 3 run) - 2026-10-24 The Examples manifest lost everything only the Showcase used: its target, product, and test target, the `Tessera`, `TesseraTerminalSnapshotSupport`, and `TesseraTerminalTestSupport` product lets, and the swift-snapshot-testing dependency with its two product lets. `Examples/Package.resolved` did not change, since the root package pins the same graph; Slice 4 re-adds `Tessera` and the harness for `DemoSupport`. `README.md` and `CONTRIBUTING.md` point at `core:example` where they named `core:showcase`, and the README's product table lists `TesseraHarness` as pre-API. (this plan, Slice 3 run) -- Open, Decision 9: how app authors consume the harness. (i) Decided 2026-10-23; see the entries above. (ii) Distribution of libghostty-vt: decided 2026-10-22 (owner) after [libghostty-vt distribution](../research/libghostty-vt-distribution.md): an SE-0482 static-library artifact bundle built by Tessera's CI once per Ghostty pin and published as a release asset behind `binaryTarget(url:checksum:)`, with the pinned headers committed in `CGhosttyVT`. Taken and implemented 2026-10-22 by [Ship libghostty-vt as a prebuilt binary](libghostty-vt-binary-distribution.md): `Package.swift` links the `ghostty-vt-f54340ad7781` release, no test job builds Zig, and [libghostty-vt distribution](../architecture/libghostty-vt-distribution.md) records the rule. (iii) How an app gets `tessera`: a per-app executable calling `HarnessCLI.main(registry:)`, a prebuilt front end (GitHub releases, Homebrew, mise) that builds and forwards to that executable, or a SwiftPM command plugin. Deferred by the owner (2026-10-22); a per-app executable is needed under every option, so Slices 4-18 build that and nothing forecloses the others; settle before Slice 19. +- 2026-10-24 The headless host runs over a device of the harness's own, not `InMemoryTerminalDevice` (owner to weigh). `TesseraTerminalTestSupport` links swift-snapshot-testing, which links `Testing.framework`, and the first `tessera apps` run died with `dyld: Library not loaded: @rpath/Testing.framework/Versions/A/Testing`: no executable can link a target that depends on it, and the harness is meant to be linked into app authors' development executables. `HeadlessTerminalDevice` is 30 lines (a size and a discarding write; the session's output tap feeds the virtual terminal), `TesseraHarness` depends on `TesseraTerminalIO` for the `TerminalDevice` seam instead of the test-support target, and the boundary allowlist forbids the test-support targets for it. `DriverTests` still pins the device's bytes through `InMemoryTerminalDevice`, which a test target may link. Proposed answer: split the snapshot-testing-free `InMemoryTerminalDevice` and `InMemoryTerminalSession` into their own target that `TesseraTerminalTestSupport` re-exports, which moves a public type between modules and its DocC pages, so it is the owner's call. (this plan, Slice 4 run) +- 2026-10-24 The driver delivers its starting size to the app as a `.resize` through the step, before the first frame (owner to weigh). The spec's runtime loop does not: it resizes the graph to the first frame's size and never tells the app, so an app has no way to learn the size it starts at, and the moved scenario corpus (`expect text 5 "40 columns"` at `size mobile`) depends on it. The spec's runtime loop now delivers the same event at the same point (critic review of PR #65), and `Application.apply`'s docs say that a host does, so the Phase 5 runtime cannot diverge from the harness; if the owner prefers another route to the starting size, the loop, the driver, and those docs change together. (this plan, Slice 4 run) +- 2026-10-24 A scenario names its app with an `app NAME` header, and the parse fails without one (owner to weigh). The spike defaulted to `workspace`, a name that existed only in its global registry; a registry is a value now, so there is no default to fall back on. `ScenarioParser.parse` takes the registry's names, `ScenarioRunner.run` takes the registry and is `async` and `@MainActor`, and `ScenarioHost` is `package`, async, and main-actor, since every step awaits `TerminalSession.draw`. Slice 5 owns the grammar and decides whether a one-app registry may omit the header. (this plan, Slice 4 run) +- 2026-10-24 A demo's snapshot test reaches `package` API with `@testable import TesseraHarness` (owner to weigh): `ApplicationRegistry.withHeadlessHost(_:at:_:)` runs a registered app headless at a viewport and hands the body a `ScenarioHost` (`screenRows`, `size`, `isFinished`, `send`). A `@testable` import of a path dependency sees its `package` declarations in a debug build (checked with a two-package scratch). There is still no public stepping API, as the stable surface requires; Slice 5's public `ScenarioRunner` is the route an app author uses, and the demo tests move to it. (this plan, Slice 4 run) +- 2026-10-24 `HarnessCLI` lives in `TesseraHarness`, and `TesseraDev` is a one-line `main.swift` over `Demos.registry()`. The slice text says `TesseraDev` "is created here as `HarnessCLI.main(registry:)`" and the Slice 3 note says Slice 4 writes `HarnessCLI` in `TesseraDev`, but the stable surface lists `HarnessCLI` among the `TesseraHarness` names and an app author's executable calls it, so it has to be in the library. `main` is `@MainActor`, because the registry's factories run there. `apps` prints a table on a terminal and one sorted-key JSON object otherwise (`--json` and `--pretty` force either); `launch` refuses unless standard input and output are both terminals, checked before the terminal changes mode, so a test or a pipe never opens a live session and a closed stdin does not leave a half-entered lifecycle to tear down; a failure is exit 2 for a usage error and 3 for a harness error, as JSON `{command, exitCode, message}` when JSON is on. `minimum` in the `apps` listing and `command` in a failure are `null`, never absent, so every entry has the same keys. Only the commands and flags are promised, so those shapes are free to change. (this plan, Slice 4 run) +- 2026-10-24 `mobile` height is the root's measured rows when no height is proposed, and an app has none if its root measures differently at 500 and at 1000 rows (owner to weigh the probe heights). `ViewGraph.measuredRootSize(proposing:)` is the `package` nil-height seam: it leaves the statistics alone and invalidates layout, so the same graph can lay out afterwards. Two tall probes replace the spike's `rows == probe` test, which could not tell a greedy root from one exactly as tall as the probe. The harness reads the app's `body` for the probe and nothing else, so one factory call serves the probe and the run. (this plan, Slice 4 run) +- 2026-10-24 Registry reads fail closed on an exact duplicate name only. A duplicate fails `checkedEntries`, `names`, and `entry(named:)` (also for a unique name), and so `apps`, `launch`, and a scenario run; `register` never throws, and nothing validates an empty `viewports` list or a name's spelling. `minimum` and `viewports` are stored and listed, not enforced: a scenario may size an app below its floor to check how it degrades, and the catalog says each component declares that degradation. (this plan, Slice 4 run) +- 2026-10-24 The driver does not reconcile terminal requirements after a draw (owner to weigh). The review loop's `ApplicationDriver` applied `graph.terminalRequirements` (mouse tracking, the keyboard protocol, focus and paste reporting) to the session after each frame; nothing at HEAD produces a requirement, and `dispatch` routes nothing, so the port stops at the step order and the update policy. Slices 9 and 10 add the reconciliation when focus and pointer input exist, in the one `Driver` both hosts share. (this plan, Slice 4 run) +- 2026-10-24 The registry and demo shapes. The two spike fixtures (`StackApp`, `WorkspaceApp`) moved out of the library into `Tests/TesseraHarnessTests/Fixtures.swift` as `Application` types, with a greedy fixture, counters of each adoption shape, and a still app, so `TesseraHarness` no longer imports `TesseraLayout` or `TesseraWidgets` and its allowlist row dropped them. `Demo` requires `name`, `minimum`, `viewports`, `fixtures`, and `init()` with no defaults; a `DemoFixture` is a name and a main-actor factory that inherits the demo's minimum and viewports; `Demos.registry()` is the one list. The counter (32x5 minimum, `desktop` and `mobile`) quits on `q` through `isQuitKey`, which compares code, modifiers, and kind, and has one fixture (`counter-high`). `ActorHost` is the per-test actor shell; its initializer is `async`, because a synchronous actor initializer is nonisolated and cannot pass `self` as the step's isolation. The driver, the three hosts, the natural-height probe, the headless device, and the stream shim are internal rather than `package` (critic review of PR #65, F2): nothing outside `TesseraHarness` needs them, since the Examples tests go through `withHeadlessHost` and the `@testable` import reaches internal declarations anyway; the step, the registry's reads, `ScenarioHost`, and `withHeadlessHost` are `package`. If a later slice needs a host from another target, widening it is one keyword. (this plan, Slice 4 run) +- 2026-10-24 The Examples tests run in CI from this slice (critic review of PR #65, F4). `ci.yml` gained a `Test Examples` step after `Test` (`swift test --package-path Examples`, on every leg), and `ci:build-test` the same line, so the counter's snapshot tests and the quit-key tests cannot regress unseen; Slice 5 adds the scenario corpus run to it. (this plan, Slice 4 run) +- 2026-10-24 The `tessera-devtools` skill is split in two (owner, Q13 option (a)). `.agents/skills/tessera/` is the published skill for app authors: the `tessera` CLI and `TesseraHarness` as an app author sees them, with one `| tessera | Does |` table, no repository paths, `mise`, demos, `DemoSupport`, or `@testable` API, and the rule that a Tessera bug is reported and not worked around. `.agents/skills/tessera-contributing/` is the in-repo contributor skill: it opens by saying everything in `tessera` applies, then adds a `| mise | tessera | Does |` table (`mise run dev:*` builds and runs the `tessera` executable), the demos and `DemoSupport`, adding a demo with its `withHeadlessHost` snapshot test, and the rule that the library is fixed with a regression test and a demo is never bent. Both skills describe only what exists, written as the finished current skill, with no future work in them. The published skill sits beside the repository's other skills so agents working here find it, and it links to nothing outside its own directory, so publishing it later is a copy of that directory; there is no symlink, because Windows checkouts handle them badly. `tessera-contributing` is named for who it serves (the repository has a `CONTRIBUTING.md`), where `tessera-devtools` named the tools and would now sit confusingly beside `tessera`. How the `tessera` skill is distributed is settled with Decision 9 (iii). This supersedes the skill sentences of the 2026-10-22 entry on the tools' design. (owner) +- 2026-10-24 The `tessera` CLI adopts swift-argument-parser, and the `dev:*` tasks complete and explain themselves (owner, review of PR #65). `HarnessCLI` declares its commands with `apple/swift-argument-parser` (`from: "1.8.2"`, the newest release, which fixes fish completion scripts) now, before the CLI surface freezes at 1.0. Only `TesseraHarness` depends on it, and `scripts/check-package-boundaries.py` allowlists that one product; `Tessera`, core, layout, widgets, and the terminal targets do not. The stable surface is unchanged: exit codes 0 to 3 (`HarnessCLI` maps ArgumentParser's usage exit 64 to 2), one JSON object when stdout is not a terminal or under `--json` and the text form under `--pretty`, `{"command","exitCode","message"}` for a failure, the command and flag names, and `help [COMMAND]`; help text and parse-error wording become ArgumentParser's. The executable completes app names itself through ArgumentParser's custom completion, with `--generate-completion-script SHELL` for fish, zsh, and bash, so `tessera launch ` works under each. A `dev:*` task run with no app name prints the command's help and the registered apps instead of mise's "Missing required arg" error, and exits 0 at a terminal and 2 in a pipe or a script, as `tessera` does, and `mise run dev:launch ` completes the names, through the usage spec's `arg "[name]"` and `complete "name" run=...`, from the already-built `Examples/.build/debug/tessera`, printing nothing rather than building when there is none. (owner) +- Open, Decision 9: how app authors consume the harness. (i) Decided 2026-10-23; see the entries above. (ii) Distribution of libghostty-vt: decided 2026-10-22 (owner) after [libghostty-vt distribution](../research/libghostty-vt-distribution.md): an SE-0482 static-library artifact bundle built by Tessera's CI once per Ghostty pin and published as a release asset behind `binaryTarget(url:checksum:)`, with the pinned headers committed in `CGhosttyVT`. Taken and implemented 2026-10-22 by [Ship libghostty-vt as a prebuilt binary](libghostty-vt-binary-distribution.md): `Package.swift` links the `ghostty-vt-f54340ad7781` release, no test job builds Zig, and [libghostty-vt distribution](../architecture/libghostty-vt-distribution.md) records the rule. (iii) How an app gets `tessera`: a per-app executable calling `HarnessCLI.main(registry:)`, a prebuilt front end (GitHub releases, Homebrew, mise) that builds and forwards to that executable, or a SwiftPM command plugin. Deferred by the owner (2026-10-22); a per-app executable is needed under every option, so Slices 4-18 build that and nothing forecloses the others; settle before Slice 19. The owner leans toward the SwiftPM command plugin (2026-10-24); still to be settled before Slice 19. ## Discoveries @@ -286,6 +298,17 @@ Rules for an agent running slices of this plan without the owner. - The critic review of PR #64 (F1) found the `Application` docs and the spec promised a compile error for `extension Notes: Application {}` on a main-actor type that does not appear in every mode. A scratch SwiftPM package with real separate modules (a path dependency on this checkout) settled the rule: it is the compiler mode of the module that writes the extension, not which module declares the type. Plain Swift 6 mode is an error ("crosses into main actor-isolated code and can cause data races") for a type declared `@MainActor` and for one isolated through its module's default isolation, in the same module or another, on Swift 6.3.3 and 6.4. With `.defaultIsolation(MainActor.self)` in the extension's module it builds on both. With `InferIsolatedConformances` it builds on both for a type declared `@MainActor`; for a type isolated only through its own module's default isolation it builds on 6.4 but errors on 6.3.3 when the extension is in another module. The first version of this note and of the docs said the difference was same module versus another module; that generalised from one cross-module `swiftc` run on 6.3.3 whose type was isolated only through default isolation, which is the 6.3 corner above, and the two-module package disproved it. The docs and the spec now state the mode rule and the 6.3 corner, and Slice 4's second fixture runs in plain Swift 6 mode (owner, approved 2026-10-24). - The critic review of PR #64 (F2) noted that after the Showcase left, no test renders a `ViewGraph` through `TerminalSession`, and nothing compiled `import Tessera` from outside the root package. The first stays open until Slice 4's driver snapshot test (its Verify line now says so); the second is guarded by `Examples/Sources/ApplicationConsumer`, a one-file library target that declares a nonisolated and a main-actor `Application` against `import Tessera` only, so the Examples build in CI fails if the re-export goes. - The critic review of PR #64 (F3) found `CLI.swift` had no caller and no test, so it was removed from the harness. `ScenarioReport.human()`, `ScenarioReport.parseFailure(_:)`, `ShotArtifact`, and the artifacts `ScenarioRunner.execute` returns now have no caller either; they are the report's human form, the parse-failure report, and the `--out` payload that Slice 5's runner and CLI build on, and they stay as moved so the port stays byte-for-byte before any addition (owner, approved 2026-10-24). +- A target that depends on `TesseraTerminalTestSupport` cannot be linked into an executable on macOS: swift-snapshot-testing pulls in `Testing.framework`, `_Testing_Foundation`, XCTest support, and SwiftUI, AppKit, and WebKit, and nothing puts them on the executable's rpath. After the harness stopped depending on it, `otool -L` on `tessera` shows one weak `libXCTestSwiftSupport.dylib`, from `IssueReporting`, which loads only if it is present. The Slice 4 decision above records the proposed split. +- SwiftPM names a module's package after the root package's identity, which is its checkout directory (`phase_4_04_driver` in this worktree, `tessera` on CI), and `package` declarations are visible only to a client with the same `-package-name`. `scripts/check-isolation-fixtures.sh` reads the identity from `swift package show-dependencies --format json`, so the fixture that calls the `package` step compiles in any checkout. +- After a new file is added to a path dependency, `swift build --package-path Examples` can keep compiling the old file list (`error: cannot find type …` from a file that exists); deleting `Examples/.build/debug.yaml` regenerates it. CI builds from clean trees and is not affected. +- A graph rooted at an `@ViewBuilder` body is dirty after every `update()`: the builder wraps even one view in a `TupleView`, which is not equatable, so the root is re-evaluated and marked, while an equatable subtree below it is skipped. The driver therefore draws after every step, and an unchanged frame costs a cursor hide and show (10 bytes) and no cell. This is the library's invalidation granularity, so the harness does not compensate; the cost counters of Slice 6 will show it, and a fixture whose body returns its root explicitly (`StillApp`) proves the driver does skip a clean graph. +- `extension MyApp: Application {}` for a type declared in another module (a test or development target, the shape the docs recommend) draws Swift's retroactive-conformance warning, because both the type and the protocol are imported. `@retroactive` silences it, and `@retroactive @MainActor Application` and `@MainActor @retroactive Application` both compile. The `Application` docs and the spec's adoption section do not say so; Slice 19's app-author path should. +- Registration-site errors, checked against the real signature: registering a main-actor app from a nonisolated context fails with "main actor-isolated conformance of … to 'Application' cannot be used in nonisolated context"; a nonisolated factory function passed as `make` fails with "converting non-Sendable function value to '@MainActor @Sendable () -> …' may introduce data races"; a closure literal marked `@Sendable` at the call site is inferred main-actor from the parameter and compiles, since it is no more than a main-actor closure that is also `Sendable`. +- The isolated-conformance diagnostics read the same on Swift 6.3.3 and 6.4: an actor host stepping a main-actor app says "main actor-isolated conformance of 'Browser' to 'Application' cannot be used in actor-isolated context" on both, and the nonisolated wording appears only for a nonisolated caller. The fixture script matches the prefix they share. Swift Build (the default from 6.4) writes modules to `/out/Products/Debug`, which the script's candidate list already handles. +- `quality:format` runs SwiftLint's `--fix`, whose `duplicate_imports` correction deletes one of `import X` and `@testable import X` from a file that has both, and it deleted the `@testable` one in three new tests; a test file imports `@testable import TesseraHarness` alone. `quality:format` does not cover `Examples/Tests`, so `Examples/Tests/Phase3ProtocolsDemoSupportTests` keeps two long lines that the commit hook warns about. +- The retired Showcase's session test is replaced by `DriverTests`, which pins the exact bytes a `Driver` writes through `TerminalSession.draw` into an `InMemoryTerminalDevice` (alternate screen in, first frame, the one changed cell, cursor and image cleanup, alternate screen out), and the `import Tessera` guard `ApplicationConsumer` provided is now `DemoSupport`, whose demos conform through `Demo: Application` from outside the root package, so the Examples build in CI still fails if `Tessera` stops re-exporting `TesseraRuntime`. +- A failed raw-mode entry tears down by writing `ESC[?1049l` although the alternate screen was never entered (`ModeLifecycle` behaviour that predates this slice, found by the critic review of PR #65): `tessera launch` with a terminal on stdout and `/dev/null` on stdin used to open the lifecycle, fail in `rawModeFailed`, and print those bytes before exit 3. `launch` now checks stdin first, so that path is closed for the CLI; the lifecycle's teardown of a mode it never entered stays as it was. +- A keyboard-driven live proof needs a pty: `expect` with `spawn -noecho sh -c "stty rows 24 cols 80; exec …"` gives the app a window size before it starts, and `log_file -a` records the bytes while `log_user 0` hides them. Replaying the log through a small cursor-addressing interpreter (cursor position, clear, printable text) gives the screens. The recipe is in the `tessera` skill. ## Outcome diff --git a/docs/spec/module-and-file-layout.md b/docs/spec/module-and-file-layout.md index 92a86f25..c6704811 100644 --- a/docs/spec/module-and-file-layout.md +++ b/docs/spec/module-and-file-layout.md @@ -147,9 +147,18 @@ Sources/ ApplicationRuntime.swift // optional @MainActor convenience loop (Phase 5) TesseraHarness/ - ApplicationRegistry.swift // registry and Viewport (public) - Driver.swift // main-actor shell over the step; headless and live hosts (package) - Scenarios/ // parser, runner, report, CLI entry point + ApplicationRegistry.swift // ApplicationRegistry (public) and its fail-closed reads (package) + Viewport.swift // Viewport (public) + HarnessCLI.swift // HarnessCLI.main(registry:) (public): parsing, dispatch, exit codes + HarnessCLI+Commands.swift // the commands and their ArgumentParser declarations (internal) + Driver.swift // main-actor shell over the step (internal) + HeadlessHost.swift // a session over a headless device into one virtual terminal (internal) + HeadlessTerminalDevice.swift // the device under a headless session (internal) + LiveHost.swift // a session over the real terminal (internal) + ActorHost.swift // per-test actor shell over the same step (internal) + NaturalHeight.swift // the mobile natural-height probe (internal) + StandardStreams.swift // the one platform question the CLI asks: are stdin and stdout terminals (internal) + Scenarios/ // parser, runner, report TesseraCore/ // declarative view values and explicit graph runtime Views/ @@ -288,7 +297,7 @@ Dependency direction inside `Tessera`: - `TesseraLayout` builds only on `TesseraCore`: the `Layout` protocol, containers, styling modifiers, and decoration. - `TesseraWidgets` builds only on `TesseraCore` + `TesseraLayout`. It starts in Slice 2 rather than waiting for Slice 7 so ScrollView and SplitView do not require a temporary target. - `TesseraRuntime` depends on `TesseraCore` and `TesseraTerminalInput` for the contract in Phase 4, and on the whole view layer plus `TesseraTerminal` once the Phase 5 runtime lands. -- `TesseraHarness` depends on `TesseraRuntime`, the view layer, and `TesseraTerminal`, and may use their `package` seams; it re-exports none of them. +- `TesseraHarness` depends on `TesseraRuntime`, `TesseraCore`, `TesseraTerminal`, and the narrow terminal modules it imports (`TesseraTerminalIO` to build the device under a headless session, `TesseraTerminalSnapshotSupport` for its virtual terminal), and may use their `package` seams; it re-exports none of them. It also takes swift-argument-parser's `ArgumentParser` for the command line, and no other target does. It never depends on `TesseraTerminalTestSupport` or `TesseraTestSupport`: those link the snapshot-testing library, and an executable that links it cannot launch (`dyld` cannot load `Testing.framework`), while the harness is linked into development executables. - The `Tessera` target re-exports the public view-layer modules, `TesseraRuntime`, and `TesseraTerminal` for app authors. Prefer Swift's access control to model intent: diff --git a/docs/spec/phase-5-runtime-and-polish.md b/docs/spec/phase-5-runtime-and-polish.md index d9598912..701c6feb 100644 --- a/docs/spec/phase-5-runtime-and-polish.md +++ b/docs/spec/phase-5-runtime-and-polish.md @@ -73,7 +73,7 @@ static func run( } ``` -The `isolated` parameter is what makes the proof work. A main-actor host passes `MainActor.shared`, so a main-actor isolated conformance is accepted; an actor host passes `self`, and the same conformance is rejected at the call site. `Task`s the step may start in Phase 5 (app effects) are created inside this isolation and stay in it. There is no optional `#isolation` default: a nonisolated caller cannot compile the call. +The `isolated` parameter is what makes the proof work. A main-actor host passes `MainActor.shared`, so a main-actor isolated conformance is accepted; an actor host passes `self`, and the same conformance is rejected at the call site. `Task`s the step may start in Phase 5 (app effects) are created inside this isolation and stay in it. There is no optional `#isolation` default, so a caller must name an isolation: a synchronous nonisolated caller cannot compile the call, and an async caller that passes another actor hops to it. ### Adopting it @@ -136,12 +136,11 @@ Inside, `run` opens the session with `withApplicationTerminal` and hands its sco ```swift private func loop(in session: TerminalSession) async throws { - var size = TerminalSize(columns: 80, rows: 24) // corrected by the first frame + let size = try await session.size let graph = ViewGraph(root: { [app] in app.body }, size: size) - try await session.draw { frame in - if frame.size != size { size = frame.size; graph.resize(to: size) } - graph.render(into: frame) - } + Step.run(app, graph: graph, event: .resize(size), isolation: MainActor.shared) // the starting size + try await session.draw { frame in graph.render(into: frame) } + if app.isFinished { return } for await event in session.events { Step.run(app, graph: graph, event: event, isolation: MainActor.shared) if graph.needsRender { try await session.draw { frame in graph.render(into: frame) } } @@ -150,6 +149,8 @@ private func loop(in session: TerminalSession) async throws { } ``` +The app learns the size it starts at the way it learns every later size: as a `.resize` through the step, before the first frame. An app has no other way to know its size, and the harness's driver delivers the same event at the same point, so an app behaves the same under either host. + What the runtime adds beyond the step is additive and arrives as a requirement with a default or as runtime configuration, never as a change to `apply`: a Tab focus policy the app opts into ([slice 4](phase-4/slice-04-focus-and-key-routing.md)), render scheduling and redraws driven by the aggregated tick requirement ([motion views](phase-4/motion-views-and-the-tick-requirement.md)), and a path for app work that completes after `apply` returns. That work is an effect: enqueued during `apply`, started after the step in the runtime's isolation, resumed there, and it edits the model without an `await`. Handlers stay synchronous inside `dispatch` (slice 4). ### The session diff --git a/mise-tasks/ci/build-test b/mise-tasks/ci/build-test index ffd2bf4c..ca72f231 100755 --- a/mise-tasks/ci/build-test +++ b/mise-tasks/ci/build-test @@ -1,7 +1,8 @@ #!/usr/bin/env bash -#MISE description="Build and test the package and build the Examples" +#MISE description="Build and test the package, build the Examples, and test them" set -euo pipefail cd "$MISE_PROJECT_ROOT" swift build --only-use-versions-from-resolved-file swift build --only-use-versions-from-resolved-file --package-path Examples swift test --only-use-versions-from-resolved-file --no-parallel +swift test --only-use-versions-from-resolved-file --no-parallel --package-path Examples diff --git a/mise-tasks/dev/apps b/mise-tasks/dev/apps new file mode 100755 index 00000000..a3de2c4c --- /dev/null +++ b/mise-tasks/dev/apps @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +#MISE description="List the applications the tessera CLI can launch (tessera apps)" +#USAGE flag "--json" help="Print one JSON object even on a terminal" +#USAGE flag "--pretty" help="Print the text table even when output is not a terminal" +set -euo pipefail +cd "$MISE_PROJECT_ROOT" +source scripts/dev-tessera.sh +args=() +if [[ "${usage_json:-}" == "true" ]]; then + args+=(--json) +fi +if [[ "${usage_pretty:-}" == "true" ]]; then + args+=(--pretty) +fi +tessera apps ${args[@]+"${args[@]}"} diff --git a/mise-tasks/dev/launch b/mise-tasks/dev/launch new file mode 100755 index 00000000..a9d3ec0b --- /dev/null +++ b/mise-tasks/dev/launch @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +#MISE description="Run one registered app in this terminal (tessera launch NAME)" +#USAGE arg "[name]" help="App name, as listed by `mise run dev:apps`; without one, shows the help and the apps" +#USAGE complete "name" run=#""$(git rev-parse --show-toplevel)/scripts/dev-app-names.sh""# +set -euo pipefail +cd "$MISE_PROJECT_ROOT" +source scripts/dev-tessera.sh +if [[ -z "${usage_name:-}" ]]; then + tessera_exit_with_help_and_apps launch +fi +tessera launch "$usage_name" diff --git a/mise-tasks/quality/architecture b/mise-tasks/quality/architecture index a0b536a0..2da6aa4c 100755 --- a/mise-tasks/quality/architecture +++ b/mise-tasks/quality/architecture @@ -1,5 +1,5 @@ #!/usr/bin/env bash -#MISE description="Verify the manifest's view-layer target graph and frame-region ownership" +#MISE description="Verify the manifest's view-layer target graph, frame-region ownership, and application isolation" set -euo pipefail cd "$MISE_PROJECT_ROOT" python3 -m unittest scripts/test_check_package_boundaries.py @@ -7,3 +7,5 @@ swift package --only-use-versions-from-resolved-file describe --type json | python3 scripts/check-package-boundaries.py swift build --only-use-versions-from-resolved-file --target TesseraTerminalBuffer scripts/check-frame-region-ownership.sh +swift build --only-use-versions-from-resolved-file --target TesseraRuntime +scripts/check-isolation-fixtures.sh diff --git a/scripts/check-isolation-fixtures.sh b/scripts/check-isolation-fixtures.sh new file mode 100755 index 00000000..955d63f1 --- /dev/null +++ b/scripts/check-isolation-fixtures.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Pins the isolation rules of the application contract with compile-fail fixtures. Each +# fixture is typechecked against the current debug modules and must fail with its expected +# diagnostic; each has a positive control (the same file with -D CONTROL) that must compile, +# so a toolchain that stops diagnosing fails this check instead of passing it. +# +# 1. An actor host given an application with a main-actor isolated conformance. +# 2. A type from a module compiled with default main-actor isolation, adopted by another +# module in plain Swift 6 mode without @MainActor on the conformance. + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +fixtures="Tests/CompileFailFixtures" +actor_fixture="$fixtures/ActorHostMainActorApp.swift" +journal_fixture="$fixtures/DefaultIsolationJournal.swift" +adopter_fixture="$fixtures/AdoptWithoutIsolation.swift" + +# Swift 6.3 says "actor-isolated context" and Swift 6.4 adds "nonisolated context" for the +# same rule, so the expectation is the shared prefix. +actor_expected="main actor-isolated conformance of 'Browser' to 'Application' cannot be used in" +adopter_expected="crosses into main actor-isolated code and can cause data races" + +# SwiftPM names a module's package after the root package's identity (the checkout +# directory's name), and `package` declarations are visible only to a client with the same +# package name. +package_name="$( + swift package --only-use-versions-from-resolved-file show-dependencies --format json | + python3 -c 'import json, re, sys; print(re.sub(r"[^A-Za-z0-9_]", "_", json.load(sys.stdin)["identity"]))' +)" + +# Ask SwiftPM where the active toolchain puts debug products: the native build system +# writes modules to `/Modules`, Swift Build (the default from Swift 6.4) to ``. +bin_path="$(swift build --only-use-versions-from-resolved-file --show-bin-path)" +module_dirs=() +for candidate in "$bin_path/Modules" "$bin_path"; do + if [[ -e "$candidate/TesseraRuntime.swiftmodule" ]]; then + module_dirs+=("$candidate") + fi +done +if [[ ${#module_dirs[@]} -eq 0 ]]; then + echo "Isolation fixtures require the current debug build products." >&2 + exit 1 +fi + +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT + +common=(-swift-version 6 -enable-experimental-feature Lifetimes) + +# Whether a diagnostic says the module could not be loaded rather than the fixture failing. +is_unloadable() { + [[ "$1" == *"cannot be imported by the"* ]] || + [[ "$1" == *"was created for incompatible target"* ]] || + [[ "$1" == *"could not find module"* ]] +} + +# Typechecks `file` against `module_dir`, expecting `expected` in the diagnostics and a clean +# compile with -D CONTROL. Prints nothing on success; returns 2 when the modules are not +# loadable by this compiler so the caller can try the next directory. +check_fixture() { + local label="$1" module_dir="$2" expected="$3" file="$4" + shift 4 + local diagnostic + if diagnostic="$(swiftc -typecheck "${common[@]}" "$@" -I "$module_dir" "$file" 2>&1)"; then + echo "Expected the $label fixture to fail type checking." >&2 + exit 1 + fi + if is_unloadable "$diagnostic"; then + return 2 + fi + if [[ "$diagnostic" != *"$expected"* ]]; then + echo "The $label fixture failed with an unexpected diagnostic:" >&2 + echo "$diagnostic" >&2 + exit 1 + fi + if ! diagnostic="$(swiftc -typecheck "${common[@]}" "$@" -D CONTROL -I "$module_dir" "$file" 2>&1)"; then + echo "The $label control did not compile:" >&2 + echo "$diagnostic" >&2 + exit 1 + fi + echo "$label probe passed" +} + +passed=0 +for module_dir in "${module_dirs[@]}"; do + status=0 + check_fixture "Actor host with a main-actor application" "$module_dir" \ + "$actor_expected" "$actor_fixture" -package-name "$package_name" || status=$? + if [[ $status -eq 2 ]]; then + continue + fi + + # The adopted type's module is compiled with default main-actor isolation; the adopting + # module is typechecked in plain Swift 6 mode, with neither InferIsolatedConformances nor + # default isolation, which the package's shared settings would otherwise add. + journal_dir="$scratch/$(basename "$module_dir")-journal" + mkdir -p "$journal_dir" + if ! swiftc -emit-module -parse-as-library -module-name JournalModel \ + "${common[@]}" -default-isolation MainActor -I "$module_dir" \ + -emit-module-path "$journal_dir/JournalModel.swiftmodule" "$journal_fixture"; then + echo "The JournalModel fixture module did not compile." >&2 + exit 1 + fi + status=0 + check_fixture "Default-isolation type adopted without @MainActor" "$module_dir" \ + "$adopter_expected" "$adopter_fixture" -I "$journal_dir" || status=$? + if [[ $status -eq 2 ]]; then + continue + fi + passed=1 + break +done + +if [[ $passed -ne 1 ]]; then + echo "Isolation fixtures require compatible current-host debug build products." >&2 + exit 1 +fi diff --git a/scripts/check-package-boundaries.py b/scripts/check-package-boundaries.py index 478d841e..737ef69d 100755 --- a/scripts/check-package-boundaries.py +++ b/scripts/check-package-boundaries.py @@ -28,24 +28,24 @@ "TesseraHarness": frozenset( { "TesseraCore", - "TesseraLayout", "TesseraRuntime", + "TesseraTerminal", "TesseraTerminalCore", "TesseraTerminalInput", + "TesseraTerminalIO", "TesseraTerminalSnapshotSupport", - "TesseraWidgets", } ), } ALLOWED_DIRECT_DEPENDENCIES["TesseraTestSupport"] = frozenset( {"TesseraCore", "TesseraTerminalTestSupport"} ) +ALLOWED_EXTERNAL_PRODUCTS: dict[str, frozenset[str]] = { + "TesseraHarness": frozenset({"ArgumentParser"}), + "TesseraTestSupport": frozenset({"SnapshotTesting", "SnapshotTestingCustomDump"}), +} ALLOWED_PRODUCT_DEPENDENCIES: dict[str, frozenset[str]] = { - target: ( - frozenset({"SnapshotTesting", "SnapshotTestingCustomDump"}) - if target == "TesseraTestSupport" - else frozenset() - ) + target: ALLOWED_EXTERNAL_PRODUCTS.get(target, frozenset()) for target in ALLOWED_DIRECT_DEPENDENCIES } diff --git a/scripts/dev-app-names.sh b/scripts/dev-app-names.sh new file mode 100755 index 00000000..68b6b7b9 --- /dev/null +++ b/scripts/dev-app-names.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Prints the registered app names, one per line, for tab completion of the `dev:*` mise tasks. +# +# Completion runs on every Tab and has to answer at once, so this asks the `tessera` binary a +# `dev:*` run already built and never builds one: with no binary it prints nothing. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tessera="$repo_root/Examples/.build/debug/tessera" + +if [[ ! -x "$tessera" ]]; then + exit 0 +fi + +# `apps --json` is the stable listing: {"apps": [{"minimum", "name", "viewports"}], ...}. +"$tessera" apps --json 2>/dev/null | grep -o '"name":"[^"]*"' | cut -d'"' -f4 || true diff --git a/scripts/dev-tessera.sh b/scripts/dev-tessera.sh new file mode 100755 index 00000000..9d5ec0cb --- /dev/null +++ b/scripts/dev-tessera.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Sourced by the `dev:*` mise tasks, which run from the repository root: it builds the +# `tessera` executable of the Examples package once and runs the binary it built. + +# Where the built executable is, once `tessera` has built it. +tessera_binary="" + +# Builds `tessera`, quietly: a build that succeeds prints nothing and one that fails still +# prints its errors. The flags are the ones every SwiftPM command here passes +# (docs/architecture/toolchain-lanes.md). +tessera_build() { + local flags=(--only-use-versions-from-resolved-file --package-path Examples) + swift build --quiet "${flags[@]}" --product tessera + tessera_binary="$(swift build --show-bin-path "${flags[@]}")/tessera" +} + +# Runs `tessera` with the given arguments, building it first the first time. A task that +# runs it twice builds once, and the binary keeps the task's terminal, as `launch` needs. +tessera() { + if [[ -z "$tessera_binary" ]]; then + tessera_build + fi + "$tessera_binary" "$@" +} + +# Ends a `dev:*` task that was run without what it needs, after the command's help has been +# printed. A person at a terminal asked a question and got the answer, so the task succeeds +# and mise reports no failure. In a pipe or a script nothing ran, which is `tessera`'s own +# usage error, so the task exits 2 and an unset variable cannot pass for success. +tessera_end_with_help() { + if [[ -t 1 ]]; then + exit 0 + fi + exit 2 +} + +# Prints the help of `tessera COMMAND`, then ends the task. +tessera_exit_with_help() { + tessera help "$1" + tessera_end_with_help +} + +# Prints the help of `tessera COMMAND` and the apps it can be given, then ends the task. +tessera_exit_with_help_and_apps() { + tessera help "$1" + echo + tessera apps --pretty + tessera_end_with_help +} diff --git a/scripts/test_check_package_boundaries.py b/scripts/test_check_package_boundaries.py index f3cb15f6..80f67fdd 100644 --- a/scripts/test_check_package_boundaries.py +++ b/scripts/test_check_package_boundaries.py @@ -44,12 +44,12 @@ def valid_package(self) -> dict[str, object]: ], "TesseraHarness": [ "TesseraCore", - "TesseraLayout", "TesseraRuntime", + "TesseraTerminal", "TesseraTerminalCore", "TesseraTerminalInput", + "TesseraTerminalIO", "TesseraTerminalSnapshotSupport", - "TesseraWidgets", ], "TesseraTestSupport": [ "TesseraCore", @@ -82,13 +82,13 @@ def test_reports_forbidden_runtime_and_harness_edges(self) -> None: assert isinstance(targets, list) by_name = {target["name"]: target for target in targets} by_name["TesseraRuntime"]["target_dependencies"].append("TesseraTerminalIO") - by_name["TesseraHarness"]["target_dependencies"].append("TesseraTerminal") + by_name["TesseraHarness"]["target_dependencies"].append("TesseraTerminalRendering") self.assertEqual( checker.check_package_boundaries(package), [ "TesseraRuntime -> TesseraTerminalIO", - "TesseraHarness -> TesseraTerminal", + "TesseraHarness -> TesseraTerminalRendering", ], ) @@ -114,6 +114,20 @@ def test_accepts_high_level_test_support_products(self) -> None: self.assertEqual(checker.check_package_boundaries(package), []) + def test_accepts_argument_parser_in_the_harness_and_nowhere_else(self) -> None: + package = self.valid_package() + targets = package["targets"] + assert isinstance(targets, list) + by_name = {target["name"]: target for target in targets} + by_name["TesseraHarness"]["product_dependencies"] = ["ArgumentParser"] + self.assertEqual(checker.check_package_boundaries(package), []) + + by_name["TesseraRuntime"]["product_dependencies"] = ["ArgumentParser"] + self.assertEqual( + checker.check_package_boundaries(package), + ["TesseraRuntime -> product ArgumentParser"], + ) + def test_requires_all_view_layer_targets(self) -> None: package = self.valid_package() targets = package["targets"]