From 79da704329fa0fa94c2ab775e9b3bac5e6240e42 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Fri, 23 Oct 2026 17:16:30 -0400 Subject: [PATCH 01/37] docs(plans): settle Phase 4 decisions 2, 5, and 8, add the colour-report slice, and record the catalog capability audit Record the owner's 2026-10-23 decisions in plan 036: Flow and ViewThatFits get their own slice before styling, the terminal colour-report probe becomes Slice 1 and runs in parallel, catalog requirements gain Focus, Pointer, Scroll, and Clock subsections that the capability slices work through, and the coverage walk reports levels, key-and-count routes, and stop reasons. Renumber the slices, add unattended-execution rules, update the developer tooling page, and add research record 043 with the audit's placements and drafted gap requirements for owner review. --- Package.resolved | 11 +- docs/architecture/developer-tooling.md | 9 +- ...hase-4-view-layer-and-developer-tooling.md | 169 +++-- docs/research.md | 1 + .../catalog-capability-requirements-audit.md | 696 ++++++++++++++++++ 5 files changed, 799 insertions(+), 87 deletions(-) create mode 100644 docs/research/catalog-capability-requirements-audit.md diff --git a/Package.resolved b/Package.resolved index 60b3dc3..a2223b2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ebe2f912ffcb71d12e788ad2f96449a893af2b76a06004a0ef7450d2cd92c6d4", + "originHash" : "fdbfe47ca73f465aeeab5b1d29ed9ab7405a5ef0674f27b2eef9b5dfe7d1d9c4", "pins" : [ { "identity" : "swift-custom-dump", @@ -37,15 +37,6 @@ "version" : "1.0.0" } }, - { - "identity" : "swift-issue-reporting", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-issue-reporting", - "state" : { - "revision" : "b23bb4493c439d3e05152bc167014235ba3d47fc", - "version" : "2.1.2" - } - }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", diff --git a/docs/architecture/developer-tooling.md b/docs/architecture/developer-tooling.md index 2d27329..3b61000 100644 --- a/docs/architecture/developer-tooling.md +++ b/docs/architecture/developer-tooling.md @@ -21,7 +21,7 @@ The design draws on Textual's pilot and snapshot tests, oh-my-pi's debug socket 2. **The runtime returns facts; the harness judges them.** Expectations are evaluated against a projection of the diagnostics the runtime already produces. `TesseraCore` holds no assertion state and gains only value-typed diagnostics fields, each by spec amendment. 3. **Text first, pixels for review.** The Ghostty cell grid is the golden. SVG and PNG pictures help a human review a change; they are never committed and never compared. 4. **Time is injected.** No step sleeps. Waiting and animation are expressed against a clock the harness advances, and are not expressible until that clock exists rather than faked with real time. -5. **A plain agent contract.** One JSON object per command when stdout is not a terminal, fixed exit codes, and errors that name the accepted vocabulary. A selector that matches nothing is an error, not an empty result. +5. **A plain agent contract.** One JSON object per command when stdout is not a terminal, fixed exit codes, and errors that name the accepted vocabulary. A selector that matches nothing is an error, not an empty result. Every output is shaped for the agent reading it: a field earns its place when it lets an agent find and fix a problem in fewer turns with less context, and a field that saves neither is left out. 6. **No test affordances in product API.** The view and terminal libraries expose no hooks for the harness: the seams it needs are `package`, never `public`, and the harness, which app authors import, uses them internally without re-exporting them. Text goldens are the only committed artefacts, and drift fails unless `--record` is passed. 7. **"Why" is answered by the trace, not by prints.** Demos find library bugs; the harness never works around them, and a demo is never bent to make a run pass. @@ -109,7 +109,7 @@ check | Observe | `shot NAME [SEL]`, `tree [SEL]`, `dump [SEL]`, `hit R,C` | | Assert | `expect text ROW "..."`, `expect screen <` shows no new public API. -### 2 Harness target and Showcase retirement +### 3 Harness target and Showcase retirement Plan 033 Step A.3 and plan 032 Step 8.1. Add `TesseraHarness` as a root-package library target (beside `TesseraTestSupport`; boundary allowlist rows in `scripts/check-package-boundaries.py` and the shared `swiftSettings`), move the spike code with its goldens byte-identical before any addition, platform code into per-OS `sources:` shims. App authors will import it (2026-10-22 decision), so its public names are chosen for them from the start, and it uses the root package's `package` seams internally without re-exporting them; its product and stability promise follow Decision 9. Delete `Examples/Sources/TesseraShowcase/`, its tests, the `core:showcase` task, and its mentions in `README.md`, `docs/project-status.md`, `CONTRIBUTING.md`, and the changelog's unreleased notes. Delivers: `swift build` and `mise run quality:architecture` green with the new target; nothing builds, runs, or links the Showcase. Verify: `mise run ci:check`; `spikes/README.md` says nothing new goes there. -### 3 Demo contract, registry, viewports, and the headless driver +### 4 Demo contract, registry, viewports, and the headless driver Plan 032 Step 8.2 and plan 033 Step B.1. `Examples/Sources/DemoSupport`: `HeadlessApp` (in `TesseraHarness`), `Demo`, `DemoRegistry` (one selection point, fails closed on duplicates), `Viewport` (`desktop` 80x24, `mobile`, `mobileScreen` 40x16, `custom`); each demo declares its `fixtures`, no default. One `Driver` with one update policy (ported from the review loop's `ApplicationDriver`) and `DemoHost.isQuitKey` whole-key matching; live and headless share the `Driver` and the `TerminalSession.draw` path. 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. Create `.agents/skills/tessera-devtools/SKILL.md` with what this slice makes usable (`demo run`, a demo's headless snapshot test) and an `AGENTS.md` pointer to it; later slices extend it. -Delivers: a demo runs live in a terminal and renders identically headless. Verify: registry tests cover duplicates and lookup; a greedy demo fails `size mobile`; one demo's snapshot test is a few lines. Before any of this, settle Decision 9 (i) with an `architect` review and record it here, since it fixes what `HeadlessApp` is. +Delivers: a demo runs live in a terminal and renders identically headless. Verify: registry tests cover duplicates and lookup; a greedy demo fails `size mobile`; one demo's snapshot test is a few lines. This slice starts only once Decision 9 (i) is recorded below, since it fixes what `HeadlessApp` is. -### 4 Scenario language, runner, `tessera-dev`, and the CI corpus +### 5 Scenario language, runner, `tessera-dev`, and the CI corpus -Plan 033 Steps B.2 and B.3. `Examples/Scenarios/README.md` is the grammar (one statement per line, shell quoting, `#` comments; `app`, `size`, `term` header; `resize`, `key`, `type`, `paste`, `bytes`; `shot`, `tree`, `dump`; `expect text|screen|cell|node|cost`; `check`; the "not yet expressible" table for `wait`, `settle`, `focus`, `mouse`). Header-binding, fail-fast parser (`FILE:LINE: error:`); the runner continues after a failed expectation; one `ScenarioReport` yields the human and JSON forms; `--record`, `--repeat N`, `--timing`; exit 0 pass, 1 expectation failed, 2 usage or parse error, 3 harness error; one JSON object per command on stdout when stdout is not a terminal. `Examples/Sources/TesseraDev` is `HarnessCLI.main(registry:)` with `demo list|run`, `run`, `shot`, `tree`, `check`, `replay`; `mise-tasks/dev/*` mirror it (`dev:run`, `dev:shot`, …, each with `#USAGE`); `help COMMAND` prints real text. CI: the `test` matrix's macOS, Linux, and Windows legs run `swift test --package-path Examples` and `tessera-dev run Examples/Scenarios`, all required (Decision 7); a `.gitattributes` rule checks goldens out with LF on every platform, so Windows' CRLF checkout cannot drift them. Amend the spec's [testing posture](../spec/phase-4/testing-posture.md) so scenarios, the harness, and `check` sit beside snapshots and unit tests as a testing layer, pointing at [Developer tooling](../architecture/developer-tooling.md) for the design; later tooling slices keep that page current the same way. +Plan 033 Steps B.2 and B.3. `Examples/Scenarios/README.md` is the grammar (one statement per line, shell quoting, `#` comments; `app`, `size`, `term` header; `resize`, `key`, `type`, `paste`, `bytes`; `shot`, `tree`, `dump`; `expect text|screen|cell|node|cost`; `check`; the "not yet expressible" table for `wait`, `settle`, `focus`, `mouse`). Header-binding, fail-fast parser (`FILE:LINE: error:`); the runner continues after a failed expectation; one `ScenarioReport` yields the human and JSON forms; `--record`, `--repeat N`, `--timing`; exit 0 pass, 1 expectation failed, 2 usage or parse error, 3 harness error; one JSON object per command on stdout when stdout is not a terminal. `Examples/Sources/TesseraDev` is `HarnessCLI.main(registry:)` with `demo list|run`, `run`, `shot`, `tree`, `check`, `replay`; `mise-tasks/dev/*` mirror it (`dev:run`, `dev:shot`, …, each with `#USAGE`); `help COMMAND` prints real text. CI: the `test` matrix's macOS, Linux, and Windows legs run `swift test --package-path Examples` and `tessera-dev run Examples/Scenarios`, all required (Decision 7); a `.gitattributes` rule checks goldens out with LF on every platform, so Windows' CRLF checkout cannot drift them. Amend the spec's [testing posture](../spec/phase-4/testing-posture.md) so scenarios, the harness, and `check` sit beside snapshots and unit tests as a testing layer, pointing at [Developer tooling](../architecture/developer-tooling.md) for the design; later tooling slices keep that page current the same way. The harness answers the terminal's colour queries itself, from the scenario's `term` header (colours known with given values, or unknown), so a styled golden never depends on the Ghostty virtual terminal's default colours. Delivers: an agent runs and reads a scenario without prose; a deliberately broken scenario fails CI. Verify: two runs give identical bytes; a parse error under `--json` is a failure with verb `parse`; a drifted golden fails without `--record`; the CI probe (break a scenario, watch the run fail, revert); the Windows leg passes the corpus with goldens checked out by Git for Windows' defaults. -### 5 Projection, locators, invariants, cost, trace, and shots, with the first demos +### 6 Projection, locators, invariants, cost, trace, and shots, with the first demos Plan 033 Step B.4 and plan 032 Step 8.3. `Projection` (sorted-key JSON of `GraphDiagnostics` with short `type` and `layout`, counters and `invalidationReasons`, no durations, a pass marker, plus the screen), `SEL` locators v0 (`type=`, `layout=`, `id=`, `text=`, `nth=`), invariants v0 (`no-dirty-after-pass`, `frame-inside-clip`, `measured-within-proposal`, `siblings-disjoint`, `requirements-aggregate`), `expect cost`, trace v0 (one JSONL record per step with `reasons`, changed nodes and rows, `cost`), and `shot` as `NAME.txt` plus `NAME.grid.txt`. Demos for the landed primitives: Text (including the archived Unicode demo), stacks and `Spacer`, `frame`/`padding`, `ZStack`, and Flex, each with a scenario that takes a `shot` at `desktop` and `mobile` and asserts one `expect node … frame`. Delivers: each landed primitive has snapshot coverage at both sizes and reads well at 40 columns; a demo's trace names the invalidation reason for each redraw. Verify: `mise run dev:run Examples/Scenarios` green; `siblings-disjoint` passes on a `ForEach` demo (the defect plan 034 fixed stays fixed). -### 6 Styling and decoration, with pictures, roles, and catalog review +### 7 Flow layout and `ViewThatFits` + +Plan 032 Step 14.1, moved before styling (Decision 2) so every later demo can wrap and switch arrangements at the 40-column `mobile` viewport; Picker's radio group wraps with Flow and `ViewThatFits` chooses its title and menu layouts, and Form, LabeledContent, ProgressView, and TabView need both. A wrapping row layout and `ViewThatFits` with layout priority, from the `ready` docs `docs/design/primitives/flow.md` and `view-that-fits.md`. Their plain requirements land here; the requirements under each doc's `### Focus` and `### Pointer` subsections (unchosen candidates leave focus and hit testing, item focus survives a reflow) are not testable yet, and Slices 9 and 10 finish them. The catalog's capability subsections (2026-10-23 decision) exist before this slice starts; if they do not, this slice adds them for every catalog doc first. + +Delivers: Flow and ViewThatFits demos with scenarios at both sizes. Verify: `check` passes with `siblings-disjoint` on a ViewThatFits demo that swaps arrangements on resize; Slice 9 adds `focus-resolves` to that scenario. + +### 8 Styling and decoration, with pictures, roles, and catalog review -Plan 032 Phase 9 (Steps 9.1-9.4) and plan 033 Phase C. Style environment, tones, and `Text + Text` styled runs measured once (9.1; attribute fallbacks until Decision 5 places the colour-report probe); word wrapping that breaks inside a word only when the word is wider than the line, `.lineLimit`, `.truncationMode` (9.2); border glyph families with ASCII fallbacks, `Box` with a View title, `background`, `overlay`, the focus border style (9.3); the shared output-only `ScrollIndicator` with thumb and track styles (9.4). Tooling: `RenderedCell.width` from Ghostty's wide flag, SVG shots as a review aid, the `theme=probe` role palette with `fg=ROLE`/`bg=ROLE` cell assertions (C.1); `expect wireframe PATH N [at SEL]`, `shot NAME SEL`, `conform`, and the gallery's Current and Previous slots fed by role-styled grids in the catalog's dialect (C.2). +Plan 032 Phase 9 (Steps 9.1-9.4) and plan 033 Phase C. Style environment, tones, and `Text + Text` styled runs measured once (9.1), resolving each role in the `derived`, `slots`, or `monochrome` tier of `docs/design/tokens.md` from Slice 1's `colorReport`; word wrapping that breaks inside a word only when the word is wider than the line, `.lineLimit`, `.truncationMode` (9.2); border glyph families with ASCII fallbacks, `Box` with a View title, `background`, `overlay`, the focus border style (9.3); the shared output-only `ScrollIndicator` with thumb and track styles (9.4). Tooling: `RenderedCell.width` from Ghostty's wide flag, SVG shots as a review aid, the `theme=probe` role palette with `fg=ROLE`/`bg=ROLE` cell assertions (C.1); `expect wireframe PATH N [at SEL]`, `shot NAME SEL`, `conform`, and the gallery's Current and Previous slots fed by role-styled grids in the catalog's dialect (C.2). Delivers: demos for text styling, truncation, borders, and indicators; styled-grid snapshots cover every glyph family and its ASCII fallback; `conform docs/design/primitives` reports pinned and unpinned fences in JSON. Verify: a focus-border demo asserts `fg=focus` under `theme=probe` and reads identically in SVG and `.txt`; SVG determinism by SHA-256 across macOS and Lima Linux. -### 7 Focus, key routing, and KeyLegend, with focus tooling +### 9 Focus, key routing, and KeyLegend, with focus tooling -Plan 032 Phase 10 (Steps 10.1-10.3) and plan 033 Phase D. Document-order focus with wrap, removal handling, and leaf-to-ancestor bubbling (ported from `phase4` `Focus.swift`, `Responder.swift`, `FocusRoutingTests`); Step 10.1 also wires the dead `focusChanges` counter (`GraphStatistics.swift`), `handlerKinds`, and `terminalRequirementChanges`. Focus appearance hosts and focus scopes (10.2); `KeyLegend` from responder key bindings, `plain` by default, `keycaps` optional, dropping whole entries by priority when narrow (10.3). Tooling: the spec amendment that adds `isFocusable` and `isFocused` to the projection, `focused`/`focusable` locators, `focus SEL`, `expect focused SEL` (D.1); the rules `exactly-one-focus`, `focus-resolves`, `focused-visible`, `focus-indicated`, `tab-cycle-terminates`, `legend-keys-handled`, and `tessera-dev coverage --app NAME` in its narrow form (D.2; Decision 8); trace `dispatch{disposition, handledBy, path}` and `focus{from, to, cause}` (D.3). +First, every component built before this slice gets the requirements under its catalog doc's `### Focus` subsection (find them with `iwe find --blocks '{ $section: Focus }'` from `docs/`, filtered to built components) as tests, so nothing built earlier skips focus; a component built later handles its own `### Focus` subsection in its own slice. Plan 032 Phase 10 (Steps 10.1-10.3) and plan 033 Phase D. Document-order focus with wrap, removal handling, and leaf-to-ancestor bubbling (ported from `phase4` `Focus.swift`, `Responder.swift`, `FocusRoutingTests`); Step 10.1 also wires the dead `focusChanges` counter (`GraphStatistics.swift`), `handlerKinds`, and `terminalRequirementChanges`. Focus appearance hosts and focus scopes (10.2); `KeyLegend` from responder key bindings, `plain` by default, `keycaps` optional, dropping whole entries by priority when narrow (10.3). Tooling: the spec amendment that adds `isFocusable` and `isFocused` to the projection, `focused`/`focusable` locators, `focus SEL`, `expect focused SEL` (D.1); the rules `exactly-one-focus`, `focus-resolves`, `focused-visible`, `focus-indicated`, `tab-cycle-terminates`, `legend-keys-handled`, and `tessera-dev coverage --app NAME` (D.2; Decision 8: per focusable, the level `shortcut`, `tab`, or `unreachable` and the shortest route as `from` plus key-and-count `steps`, arrow steps counted in the route but not as presses, plus why the walk stopped); trace `dispatch{disposition, handledBy, path}` and `focus{from, to, cause}` (D.3). Delivers: a focus demo and a KeyLegend demo with scenarios asserting `focused`; routing tables cover focused, bubbled, ignored, and removed responders. Verify: coverage reports no `unreachable` focusable in any demo; a swallowed key's trace names the node that consumed it; an invisible-focus fixture fails `focused-visible`; a `colors=none` run fails `focus-indicated` when focus has no glyph. -### 8 Mouse, hit testing, and hover, with pointer tooling +### 10 Mouse, hit testing, and hover, with pointer tooling -Plan 032 Phase 11 (Steps 11.1-11.3) and plan 033 Phase E. Clipped, deepest-first hit testing in paint order, click-to-focus, pointer capture (ported from review-loop `ab272c2`); hover state with any-event tracking requested only while a handler needs it, cleared on focus loss; application-owned text selection and copy per `docs/design/primitives/text-selection.md`, or an explicit deferral recorded in the spec. Tooling: `mouse click|move|scroll|down|up R,C`, `hit R,C` returning the deepest node path and whether it captured; traces record capture changes. +First, every component built before this slice gets the requirements under its catalog doc's `### Pointer` subsection (find them with `iwe find --blocks '{ $section: Pointer }'` from `docs/`, filtered to built components) as tests, so nothing built earlier skips pointer input; a component built later handles its own `### Pointer` subsection in its own slice. Plan 032 Phase 11 (Steps 11.1-11.3) and plan 033 Phase E. Clipped, deepest-first hit testing in paint order, click-to-focus, pointer capture (ported from review-loop `ab272c2`); hover state with any-event tracking requested only while a handler needs it, cleared on focus loss; application-owned text selection and copy per `docs/design/primitives/text-selection.md`, or an explicit deferral recorded in the spec. Tooling: `mouse click|move|scroll|down|up R,C`, `hit R,C` returning the deepest node path and whether it captured; traces record capture changes. Delivers: a pointer demo; the overlap, clipping, capture, and de-escalation tests exist as scenarios. Verify: `mise run dev:run` over the pointer scenarios; the requirement de-escalation shows in the trace. -### 9 Interactive ScrollView, with scroll checks +### 11 Interactive ScrollView, with scroll checks -Plan 032 Phase 12 (Steps 12.1-12.2) and plan 033 Step G.2. Keyboard and wheel scrolling, boundary bubbling to an enclosing viewport, focus only while scrollable; indicators stay output-only (a click or drag on one returns `.ignored`); reveal on focus change (`docs/research/focus-appearance-host-inside-scroll-content.md`). Rules `scroll-offset-within-content`, `selection-visible`, `unique-collection-ids`; `expect node SEL visible` understands scroll clips. +First, every component built before this slice gets the requirements under its catalog doc's `### Scroll` subsection (find them with `iwe find --blocks '{ $section: Scroll }'` from `docs/`, filtered to built components) as tests, so nothing built earlier skips interactive scrolling; a component built later handles its own `### Scroll` subsection in its own slice. Plan 032 Phase 12 (Steps 12.1-12.2) and plan 033 Step G.2. Keyboard and wheel scrolling, boundary bubbling to an enclosing viewport, focus only while scrollable; indicators stay output-only (a click or drag on one returns `.ignored`); reveal on focus change (`docs/research/focus-appearance-host-inside-scroll-content.md`). Rules `scroll-offset-within-content`, `selection-visible`, `unique-collection-ids`; `expect node SEL visible` understands scroll clips. Delivers: a ScrollView demo with nested viewports at both sizes, ending with `check`. Verify: moving focus into hidden content scrolls it into view in a scenario. -### 10 Flow layout and `ViewThatFits` - -Plan 032 Step 14.1, moved before the controls (Decision 2): Picker's radio group wraps with Flow and `ViewThatFits` chooses its title and menu layouts, and Form, LabeledContent, ProgressView, and TabView need both. A wrapping row layout and `ViewThatFits` with layout priority, from the `ready` docs `docs/design/primitives/flow.md` and `view-that-fits.md`; the rule that unchosen candidates leave focus and hit testing is testable now that Slices 7 and 8 exist. - -Delivers: Flow and ViewThatFits demos with scenarios at both sizes. Verify: `check` passes with `siblings-disjoint` and `focus-resolves` on a ViewThatFits demo that swaps arrangements on resize. - -### 11 Roles and labels in the projection +### 12 Roles and labels in the projection Plan 033 Step G.1 (before the first control). `role` and `label` as value-typed, optional projection fields set by library controls through an internal hook (no public view API), added by spec amendment in this slice; `role=`/`label=` locators; nodes without a role fall back to one derived from `type`, and without a label to their screen text (Decision 4). No app-facing label modifier in 1.0; the fields are shaped so a later accessibility or Tern Surface Protocol export can read them. -Delivers: locators address controls by meaning. Verify: a scenario finds a Button by `role=button label="Submit"` before Slice 12 lands. +Delivers: locators address controls by meaning. Verify: a scenario finds a Button by `role=button label="Submit"` before Slice 13 lands. -### 12 Controls +### 13 Controls Plan 032 Phase 13 (Steps 13.1-13.4). Each control lands with its demo, keyboard and pointer paths, and disabled state, driven by `role=`/`label=` locators, never coordinates. Button with the three emphases and the shape set with ASCII ends, the pressed state reaching rendering; Toggle (`.checkbox`, `.circle`) with the Form row focus rule; Picker as radio group (chips wrap with Flow), menu (with the "Other" row), and segmented (icons-only and tail truncation when narrow), and MultiPicker; Stepper (arrows visible unfocused, the limit arrow disabled, clamped stepping and overflow tests); TextField on a shared grapheme-safe text editing core with the character counter (ported from review-loop `TextFieldEditor.swift`). Delivers: four control demos with scenarios. Verify: every control scenario uses semantic locators; `check` passes; the pressed Button state is visible in a shot. -### 13 Collections +### 14 Collections Plan 032 Steps 14.2-14.4. List and Section (one focus stop, controlled selection, the shared indicator, badges on rows); Outline with path mode (expanders, guides, a root folder icon, badges before icons, path folding); Grid on the shared Flex solver and Table with `.plain`, `.bordered`, and `.grid` styles plus alternating row backgrounds, sorted by column identity. Delivers: List, Outline, Grid, and Table demos ending with `check`. Verify: `unique-collection-ids` fails closed on a duplicate-ID fixture; `selection-visible` holds while scrolling a List. -### 14 Interactive SplitView +### 15 Interactive SplitView Plan 032 Step 15.1. Divider keyboard and mouse resizing (arrow and Shift+arrow steps, a wider grab target, double-click or `=` to reset), the focus style, clipping without automatic collapse, the ASCII junctions, nesting, and content-minimum sizing; ported from review-loop `SplitView.swift` after fixing its duplicate-ID trap and its binding writes during update. Also settle the pane-content rule plan 034 discovered (a pane whose content is a nested multi-view list places only the first view): one view per pane is an error or a documented rule. Delivers: a SplitView demo covering the five edge cases. Verify: scenarios drive the divider by keyboard and pointer and assert pane frames. -### 15 The animation path and the schedulable clock +### 16 The animation path and the schedulable clock -Plan 032 Step 16.1 and plan 033 Decision 7. A clock in the environment that the harness can advance, and a declared tick requirement aggregated like `TerminalRequirements`; with it, the scenario verbs `wait` and `settle` and the motion views become expressible (they are listed as not expressible until here). The seams-spike clock feeds pass durations only, so this is a design step, not a port. +First, every component built before this slice gets the requirements under its catalog doc's `### Clock` subsection (find them with `iwe find --blocks '{ $section: Clock }'` from `docs/`, filtered to built components) as tests, so nothing built earlier skips the clock; a component built later handles its own `### Clock` subsection in its own slice. Plan 032 Step 16.1 and plan 033 Decision 7. A clock in the environment that the harness can advance, and a declared tick requirement aggregated like `TerminalRequirements`; with it, the scenario verbs `wait` and `settle` and the motion views become expressible (they are listed as not expressible until here). The seams-spike clock feeds pass durations only, so this is a design step, not a port. Delivers: a spinner demo whose scenario advances the clock deterministically. Verify: two runs give identical bytes; no scenario sleeps. -### 16 Remaining 1.0 views +### 17 Remaining 1.0 views Plan 032 Steps 16.2-16.3. `ProgressView` (determinate and indeterminate spinner sets), `Callout`, `LabeledContent`, `Label` with the icon catalog, `Badge`, `ContentUnavailableView`; then TabView, Form, Markdown, the code view, and `Image`, per `docs/spec/phase-4/` "The 1.0 surface". Delivers: a demo per view with scenarios at both sizes. Verify: `conform` pins every `implemented` fence these views graduate. -### 17 Live inspection +### 18 Live inspection Plan 033 Phase F, under Decision 3 (b). First amend the spec's diagnostics paragraph: `TesseraCore` still serializes nothing; a dev runner outside the library may expose an opt-in, local, same-user endpoint that serializes the harness projection and injects input through the normal step path. Then the Unix-domain NDJSON endpoint behind `TESSERA_DEBUG_SOCKET` (`text`, `grid`, `tree`, `stats`, `shot`, `key`, `type`, `mouse`, `resize`, `quit`), `tessera-dev attach`, `quit` unlinking before replying, `ctl` exit 3 for missing, stale, silent, and unsupported; excluded on Windows for now (Windows 10 and later have `AF_UNIX`, so the spike's exclusion can be revisited). Nothing else depends on it. Delivers: a demo in a real terminal answers `text`, `tree focused`, and `key Tab` from a second shell. Verify: the transport-parity test runs every scenario in-process and over the socket with identical reports. -### 18 Docs, skill, and the conformance gate +### 19 Docs, skill, and the conformance gate -Plan 033 Phase H. The final review and revision of the `tessera-devtools` skill that Slice 3 created and each tooling slice extended (the loop: `demo run` → `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 adopts the harness, writes scenarios, and runs `check` in its own CI); the spec's diagnostics paragraph checked against the amendments Slices 11 and 17 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 the `tessera-devtools` skill that Slice 4 created and each tooling slice extended (the loop: `demo run` → `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 adopts the harness, writes scenarios, and runs `check` in its own CI); 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). -### 19 Intro app and Phase 4 closure +### 20 Intro app and Phase 4 closure Plan 032 Steps 17.1-17.2. A Textual-style intro app that composes the per-component demos (navigation only, no component behaviour), with a scenario per screen; move each implemented doc's durable content into DocC, mark it `implemented`, run the performance and platform checks, and the full quality gate. Delivers: Phase 4 closed. Verify: every catalog page for a shipped view is `implemented` and pinned; `mise run ci:check`, `mise run docs:lint`, and the Lima and Windows suites green. +## Unattended execution + +Rules for an agent running slices of this plan without the owner. + +- Base: this plan's current decisions live on the docs branch `docs/phase-4-decisions` until its pull request merges, and on `main` afterwards. Create each run's worktree from that base with `wt switch --create BRANCH --base origin/docs/phase-4-decisions` (or `--base origin/main` once it has merged), so the worktree hooks run, and do all work there; the `main` worktree is not touched. +- Branches and pull requests: one branch and one draft pull request per slice, with a Conventional Commit title. Slice 1 is `phase-4/01-terminal-colour-reports`; Slice N is `phase-4/NN-`. A slice that depends on an unmerged slice branches from that slice's branch and targets it; every other pull request targets the docs branch until it merges and `main` afterwards. Commit at least once per slice and push after each commit so CI runs. +- Never: push to `main`, merge a pull request, force-push a branch another run uses, delete a branch, worktree, tag, or release, create tags or releases, or change repository settings or rulesets. +- Checks before each commit: the `AGENTS.md` gate, `mise run quality:format`, the full `swift test` on the baseline toolchain (`DEVELOPER_DIR=/Applications/Xcode-26.6.0.app/Contents/Developer`), and `mise run quality:lint`; add `mise run docs:lint` when DocC or public API changes, and `swift test --package-path Examples` once the Examples package carries the change. mise is not activated in a plain shell; use `mise exec --` or `mise run`. A new worktree needs `npm ci` before `quality:format`. +- VMs: the Lima guest `tessera-linux` and the UTM guest `tessera-windows-11` are shared between runs; start them with `mise run vm:linux-start` and `mise run vm:windows-start`, deliver a worktree with the `*-push` tasks, run one VM test at a time per guest, and leave them running at the end. +- CI waits: watch runs with `gh run watch` or `gh run view`; a hosted-runner flake (a cancelled leg, a runner that never starts) is retried once with `gh run rerun --failed` and recorded in Discoveries. +- Handoff: after each slice, check its box only with evidence on the line (a SHA, a run ID, a command and its result), update `Next:`, and record Decisions and Discoveries as they happen. The run will outlast one context; the plan is the handoff. Plan edits made on a slice branch land with that slice's pull request. +- Owner decisions: a slice whose text names an open decision (Decision 9 (i) for Slice 4) does not start until the decision is recorded under Decisions. A design question a slice meets that its catalog doc, the spec, and this plan do not answer is recorded in the slice and in Discoveries with a proposed answer, and the slice continues only where the answer cannot change public API or a catalog doc's promises. +- Stuck: before recording a slice as blocked, spawn an `architect` agent at medium effort with the slice, what was tried, and the first error, and try what it proposes. Record its advice and the result in Discoveries either way. +- Blocked slices: record what was tried, the architect's advice, and the first error in the slice, leave the box unchecked, and stop that run's chain; slices that do not depend on it may still run. +- Left for the owner: reviewing and merging the pull requests, and every open decision. + ## Acceptance - [ ] Every open step of plans 032 (Phases 8-17) and 033 (A.2 onward) is covered by a slice above, and no step remains only in the cancelled plans. - [ ] Each component lands with its tests, its demo, and its scenario in one reviewable step, from a `ready` catalog doc. - [ ] `mise run dev:run Examples/Scenarios` passes on macOS, Linux, and Windows in CI; committed goldens are text only and drift fails without `--record`; two runs give identical bytes. - [ ] An app outside this repository's package graph depends on Tessera, adopts the harness, and runs a scenario with `check` in its own CI. -- [ ] By Slice 7, focus is addressable, traced with cause, invariant-checked, and coverage-walked; by Slice 12, control demos use semantic locators; by Slice 19, `conform` gates `implemented` docs. +- [ ] By Slice 9, focus is addressable, traced with cause, invariant-checked, and coverage-walked; by Slice 13, control demos use semantic locators; by Slice 20, `conform` gates `implemented` docs. - [ ] The spec's diagnostics paragraph carries every amendment this plan makes, and no test affordance is public API. - [ ] [Developer tooling](../architecture/developer-tooling.md) describes every tool as built, and no tool that has landed is still described only as designed. @@ -187,25 +210,27 @@ Delivers: Phase 4 closed. Verify: every catalog page for a shipped view is `impl - 2026-10-20 Scenario input is typed `InputEvent` verbs with a `bytes` escape hatch. (owner, plan 033 Decision 6) - 2026-10-21 The headless driver lives in the harness library (`TesseraHarness`, root-package test support), and each demo declares its viewports. (owner, plan 034 R-10 and plan 033 Decision 3) - 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 exist (Slice 10), because the Picker family and the Phase 16 views depend on them and their focus rule is only testable once Slices 7 and 8 land. (this plan, from the 2026-10-21 owner review note under plan 032 Step 13.2; the owner confirms on review) -- 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 3 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 1 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 3 so the agents building later slices use it, and Slice 18 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 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 17 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 11); 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 4, 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) +- 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 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) - 2026-10-22 App authors get scenarios and checks: `TesseraHarness` becomes a library app authors import to drive and check their own apps, because agent-driven development of Tessera apps depends on it. This reverses plan 033's "not offered to app authors". (owner) -- Open, Decision 2: Slice 10's position, as proposed above. -- Open, Decision 5: where the Phase 3 colour-report probe lands (a step before or inside Slice 6, or a separate plan); Slice 6 renders attribute fallbacks until then. -- Open, Decision 8: the shape of the coverage walk's output (Ternbook's route data or the narrow form); the narrow form is Slice 7's default. -- Open, Decision 9: how app authors consume the harness. (i) The public surface: `HeadlessApp` as designed here, or driving the Phase 5 runtime's scene (`TestRuntime(scene:)` in `docs/spec/phase-5-runtime-and-polish.md`), and which names carry a 1.0 stability promise. Deferred by the owner (2026-10-22) to the start of Slice 3, settled there with an `architect` review of the two candidates before `HeadlessApp` is fixed. (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-dev`: 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 3-17 build that and nothing forecloses the others; settle before Slice 18. +- 2026-10-23 Decision 2, Flow and `ViewThatFits`: they get their own slice (Slice 7) between the projection slice and styling, before focus exists, so every later demo can lay out well at 40 columns; neither needs styling. Their focus and pointer requirements are finished by Slices 9 and 10. (owner) +- 2026-10-23 Capability requirements: each catalog doc's `## Requirements` gains `### Focus` (all keyboard handling and focused appearance included), `### Pointer`, `### Scroll` (interactive scrolling only), and `### Clock` subsections, present only where the component needs that capability, enforced by the IWE design schema. A requirement needing several capabilities goes under the one that lands last; negative assertions ("installs no responder") need the machinery, so they go in the subsection too. Slices 9, 10, 11, and 16 start by making every already-built component's subsection pass. (owner) +- 2026-10-23 Decision 5, the terminal colour-report probe: a slice of this plan (Slice 1) that depends on no other slice and runs in parallel, so styling is built and reviewed in the `derived` tier; the harness answers colour queries from the scenario's `term` header so goldens never depend on Ghostty's defaults. (owner) +- 2026-10-23 Decision 8, the coverage walk: the levels `shortcut`, `tab`, and `unreachable`, the shortest route as key-and-count steps, and the stop reason, without Ternbook's app-only levels and landmarks. The rule behind it covers every tool: an output field earns its place when it lets an agent find and fix a problem in fewer turns with less context. (owner) +- 2026-10-23 Slices renumbered: the colour-report slice is Slice 1, and Flow and `ViewThatFits` move from Slice 10 to Slice 7, so former Slices 1-5 are now 2-6, 6-9 are 8-11, 10 is 7, and 11-19 are 12-20; entries above use the new numbers. (this plan) +- Open, Decision 9: how app authors consume the harness. (i) The public surface: `HeadlessApp` as designed here, or driving the Phase 5 runtime's scene (`TestRuntime(scene:)` in `docs/spec/phase-5-runtime-and-polish.md`), and which names carry a 1.0 stability promise. Deferred by the owner (2026-10-22); on 2026-10-23 the owner asked for a `senior` review (spikes allowed) to settle it before Slice 4, so Slices 4-7 can run unattended; the owner decides from that review. (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-dev`: 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. ## Discoveries - `ForEach` and other view lists inside stacks laid every item at the same frame (spikes 1, 2, and 4; plan 033) — fixed by plan 034 Step 10.1 (`f4172e8`), with `siblings-disjoint` as the harness rule that would have caught it. -- A `SplitView` pane whose content is a nested multi-view list now places only its first view (plan 034 Step 10.1 discovery) — Slice 14 settles the rule. -- `Text` does not wrap at HEAD and ignores its width proposal (plan 033) — Slice 6. -- `handlerKinds` and the focus and requirement counters are dead at HEAD (plan 033; `focusChanges` confirmed in plan 034 R-10) — Slice 7. -- The seams spike's `sizeChanges` fan-out was superseded by plan 034 Step 6.6, which fans one subscription out newest-only — Slice 1 adapts the patch rather than applying it. +- A `SplitView` pane whose content is a nested multi-view list now places only its first view (plan 034 Step 10.1 discovery) — Slice 15 settles the rule. +- `Text` does not wrap at HEAD and ignores its width proposal (plan 033) — Slice 8. +- `handlerKinds` and the focus and requirement counters are dead at HEAD (plan 033; `focusChanges` confirmed in plan 034 R-10) — Slice 9. +- The seams spike's `sizeChanges` fan-out was superseded by plan 034 Step 6.6, which fans one subscription out newest-only — Slice 2 adapts the patch rather than applying it. ## Outcome diff --git a/docs/research.md b/docs/research.md index 64b1bb8..cdf9e82 100644 --- a/docs/research.md +++ b/docs/research.md @@ -43,3 +43,4 @@ Research records answer one question each: what was observed, with citations tha - 040 (2026-10-19) [Phase 4 tooling context and prior attempts](research/phase-4-tooling-context-and-prior-attempts.md) - 041 (2026-10-19) [TUI Devtools Prior Art](research/tui-devtools-prior-art.md) - 042 (2026-10-22) [libghostty-vt distribution for version-based consumers](research/libghostty-vt-distribution.md) +- 043 (2026-10-23) [Catalog capability requirements audit](research/catalog-capability-requirements-audit.md) diff --git a/docs/research/catalog-capability-requirements-audit.md b/docs/research/catalog-capability-requirements-audit.md new file mode 100644 index 0000000..d2f360f --- /dev/null +++ b/docs/research/catalog-capability-requirements-audit.md @@ -0,0 +1,696 @@ +--- +type: research +observed: 2026-10-23 +status: open +description: Which catalog requirements need focus, pointer, scroll, or the clock before they can be tested, where each goes under the capability subsections, and the behaviours no requirement covers yet. +--- + +# Catalog capability requirements audit + +## Question + +The owner decided (2026-10-23, [plan 036](../plans/phase-4-view-layer-and-developer-tooling.md) Decisions) that each catalog doc's `## Requirements` gains `### Focus`, `### Pointer`, `### Scroll`, and `### Clock` subsections for requirements that can't be tested until that capability lands. Which of the catalog's 1071 requirements go where, and which behaviours that the designs commit to have no requirement at all? + +## Method + +Four classifier agents marked every requirement for one capability each; an independent engineer judged, without reading any Requirements section, whether each of the 43 in-scope components owns or affects each capability and which behaviours it commits to; a second engineer placed every marked requirement by the latest-landing rule (Focus, then Pointer, then Scroll, then Clock), resolved the borderlines, and matched each committed behaviour to a requirement, drafting one where none existed. Nothing in the catalog was edited. Status stays `open` until the owner reviews the drafted gap requirements and the disagreements; the restructuring then applies the placements and the accepted drafts. + +Inputs: `requirements.tsv` (1071 rows), `req-merged.jsonl` (319 marked rows), `participation.jsonl` (43 docs), classifier `why` fields in `req-{focus,pointer,scroll,clock}.jsonl`, and the source docs under `docs/design/` where a verdict turned on doc text. Read-only; no doc edited. + +Landing order: Focus < Pointer < Scroll < Clock. Rule: a requirement goes under the capability that lands LAST among those it needs; negative assertions ("installs no responder") go in the subsection of the capability whose machinery they need. Conventions used below: + +- **Resolving a NodeState offset.** An offset that is `NodeState` (List, Table, Outline, Form, Picker/MultiPicker menus) is zero until input or reveal moves it, so any assertion that observes a non-zero offset is Scroll. An offset that is a `Binding` (ScrollView) can be set by the test, so clamp/geometry assertions on it are plain. +- **Combined negatives.** The catalog already writes "takes no focus, keys or pointer input" as one requirement (text.md:371, image.md:394, markdown.md:506). Gap drafts follow that convention; the row lists both capabilities and the subsection is the later one (Pointer). +- **Paging.** PgUp/PgDn that move the SELECTION or CURSOR by the static viewport row count are Focus (see EXCEPTION rows); PgUp/PgDn/Home/End that move the OFFSET (ScrollView) are Scroll. + +## 1. Placement + +Table covers all 319 marked rows once each (`src=M`) plus 33 unmarked rows found to belong in a subsection (`src=ADD`). `none` means the borderline resolved to the plain list. + +| loc | src | requirement name | subsection | note | +|---|---|---|---|---| +| primitives/background.md:147 | M | decorative backgrounds require explicit allows hit testing false | Pointer | Asserts the allowsHitTesting(false) opt-out; hit testing is Pointer machinery. | +| primitives/badge.md:325 | M | badge takes no focus and consumes no keys | Focus | negative focus and key-routing assertion | +| primitives/border.md:253 | M | self focus paints a heavy ring in the focus role without moving the ring | Focus | self focus state drives ring style | +| primitives/border.md:254 | M | focus within tints the nearest bordered ring at normal weight | Focus | focus-within state drives ring tint | +| primitives/border.md:255 | M | focused row shows the cursor and a bold focus label | Focus | focused-row cursor and label | +| primitives/border.md:256 | M | split divider and joints take the focus role as heavy lines | Focus | focused divider appearance | +| primitives/border.md:257 | M | bordered table outer ring follows the self or within focus rule | Focus | focus rule selects ring style | +| primitives/border.md:258 | M | heavy and double rings change colour only on focus | Focus | focus appearance | +| primitives/border.md:259 | M | ascii focus renders bold on the ascii ring | Focus | focus appearance under ASCII | +| primitives/border.md:260 | M | focus ring takes the focus role in every colour tier, with no hue | Focus | focus ring appearance | +| primitives/box.md:343 | M | self focus paints the heavy set in the focus role without moving the ring | Focus | self focus state drives ring style | +| primitives/box.md:344 | M | focus within tints the nearest bordered ring at normal weight | Focus | focus-within state drives ring tint | +| primitives/box.md:345 | M | focus within title takes the bold focus role | Focus | focus-within state drives title style | +| primitives/box.md:346 | M | nested focus tints only the nearest bordered ancestor | Focus | focus propagation through ancestors | +| primitives/box.md:347 | M | box with nothing focusable inside never changes | Focus | negative focus-within assertion | +| primitives/box.md:348 | M | focus within is off under focusSection or focusWithinBorder(false) | Focus | focus-within tint switch | +| primitives/flow.md:313 | M | flow item node state survives a reflow | Focus | focus identity survives reflow | +| primitives/overlay.md:117 | M | decorative overlay requires explicit hit-testing opt-out | Pointer | Asserts hit-testing opt-out for an overlay layer; needs hit testing. | +| primitives/scroll-indicator.md:377 | M | scroll indicator installs no focus binding input or drag handler | Pointer | caps focus+pointer; latest-landing -> Pointer. Negative assertion that no drag handler is installed; pointer drag routing is Pointer. | +| primitives/text.md:371 | M | text never consumes keyboard or pointer input | Pointer | caps focus+pointer; latest-landing -> Pointer. Negative assertion over mouse table (click, drag, wheel-up/down); pointer half needs Pointer, key half needs Focus. | +| primitives/view-that-fits.md:303 | M | unchosen candidates get a zero frame and render and hit test nothing | Pointer | Negative hit-test assertion for unchosen candidates. | +| primitives/view-that-fits.md:312 | M | an unchosen candidate keeps its node state until it is chosen again | Focus | BORDERLINE -> Focus. Node state lives on a candidate node; the earliest capability that gives a candidate mutable state is Focus (key-driven state: pressed, cursor). The ScrollView-clamp half of the trace is a Scroll behaviour and is drafted separately (Gaps: view-that-fits / scroll). | +| primitives/view-that-fits.md:313 | M | unchosen candidates are left out of the focus order | Focus | focus order membership | +| primitives/view-that-fits.md:314 | M | focus stays on a focus id that the newly chosen candidate also holds | Focus | focus retention across switch | +| primitives/view-that-fits.md:315 | M | focus clears when the newly chosen candidate lacks the focused id | Focus | focus removal handling | +| primitives/view-that-fits.md:316 | ADD | unchosen candidates still contribute terminal requirements | Focus | UNMARKED ADD. Terminal requirements aggregation belongs to slice 04 (focus and key routing; view-that-fits.md links terminal-requirements-aggregation). Participation lists it under Pointer via "mouse tracking" as the example; the machinery is Focus (see Disagreements D4). | +| widgets/button.md:579 | M | focused box draws its border heavy in focus | Focus | focused appearance | +| widgets/button.md:583 | M | focused filled button takes the focus fill with a bold contrast label | Focus | focused appearance | +| widgets/button.md:584 | M | focused filled button draws a default-foreground chip | Focus | focused appearance | +| widgets/button.md:585 | M | focused tinted button takes a faint focus fill with a bold focus label | Focus | focused appearance | +| widgets/button.md:586 | M | focused plain button underlines its label in focus | Focus | focused appearance | +| widgets/button.md:587 | M | pressed filled button takes the tinted look and pressed tinted takes the filled look | Focus | BORDERLINE -> Focus. isPressed is reachable from a focused phased key-down (Key table: Enter/Space), so the pressed look is testable at Focus without any pointer. | +| widgets/button.md:588 | M | pressed plain button renders its label bold | Focus | BORDERLINE -> Focus. Same as button.md:587: pressed is set by a focused key-down. | +| widgets/button.md:589 | M | hovered button underlines its label | Pointer | Hover state is pointer-derived (is hovered). | +| widgets/button.md:592 | M | NO_COLOR focus underlines the label | Focus | focus appearance under NO_COLOR | +| widgets/button.md:593 | M | ASCII focus adds bold to the label | Focus | focus appearance under ASCII | +| widgets/button.md:598 | M | filled button uses its full forced width as its touch-wide hit target | Pointer | Asserts the clickable region (hit target), which requires hit testing. | +| widgets/button.md:599 | M | button invokes its action exactly once for a phased enabled enter press and matching release | Focus | Enter key routed to focused responder | +| widgets/button.md:600 | M | button invokes its action exactly once for a legacy enabled enter press-only event | Focus | Enter key routed to focused responder | +| widgets/button.md:601 | M | button invokes its action exactly once for a phased enabled space press and matching release | Focus | Space key routed to focused responder | +| widgets/button.md:602 | M | button invokes its action exactly once for a legacy enabled space press-only event | Focus | Space key routed to focused responder | +| widgets/button.md:603 | M | legacy keyboard activation clears isPressed before dispatch returns | Focus | keyboard activation | +| widgets/button.md:604 | M | button cancels a pending press when focus or enabled state changes before release | Focus | BORDERLINE (pointer) -> Focus. The keyboard path (phased key-down, then focus loss or disable before release) fully exercises the rule; the pointer press-cancel variant is covered by the gap `button clears a pending press when a pointer release leaves its node` (Pointer). | +| widgets/button.md:605 | M | disabled button neither joins focus nor invokes or consumes activation input | Pointer | caps focus+pointer; latest-landing -> Pointer. Mouse table disabled-click negative assertion. | +| widgets/button.md:606 | M | button activates a primary pointer only when press and release hit the same button node | Pointer | Same-node click semantics; pointer press/release hit testing. | +| widgets/button.md:607 | M | button shows focus only for its explicit matching focus identity | Focus | focus identity matching | +| widgets/button.md:608 | M | button exposes transient isPressed and isHovered only to style rendering | Pointer | Includes isHovered, which is pointer-derived hover. | +| widgets/button.md:609 | M | button requests pointer-move input only through a hover consumer in its subtree | Pointer | Pointer-move request/subscription behaviour. | +| widgets/button.md:610 | M | button hover underline clears when the graph clears its hover target on focus loss | Pointer | caps focus+pointer; latest-landing -> Pointer. Hover target lifecycle is pointer state (mouse table move). | +| widgets/button.md:611 | M | default action fires on Return when the focused responder does not consume it | Focus | Return key routing | +| widgets/button.md:612 | M | cancel action fires on Esc when no press is pending and the focused responder does not consume it | Focus | Esc key routing | +| widgets/button.md:613 | M | focused and pressed button applies the pressed trade to its focused look | Focus | focused appearance with pressed state | +| widgets/form.md:1235 | M | form paints the cursor glyph and the bold focus label on the focused row only | Focus | focused-row cursor and label | +| widgets/form.md:1236 | M | form draws the cursor only while focused | Focus | focus-gated cursor | +| widgets/form.md:1237 | M | form draws no cursor and no bold label while unfocused | Focus | unfocused appearance | +| widgets/form.md:1238 | M | form writes a field value only through its binding | Focus | BORDERLINE -> Focus. Field values are written by keys (Left/Right, Enter/Space, editing); the binding-only rule is observable from the first key. | +| widgets/form.md:1239 | M | form derives its hovered row from graph-owned hover, requests move input for it, and changes no value on move | Pointer | Hover derivation and move-input request. | +| widgets/form.md:1243 | M | focused enum row paints its value between secondary arrows, as the number row does | Focus | focused appearance | +| widgets/form.md:1248 | M | number value paints primary and shows its arrows on every number row, focused or not | Focus | asserts across focus states | +| widgets/form.md:1249 | M | number arrows paint secondary and stay in place around the value | Focus | focused number field appearance | +| widgets/form.md:1250 | M | select row opens Picker's menu below its row, with the cursor on the chosen option | Focus | opening via enter or space key | +| widgets/form.md:1251 | M | multi row opens MultiPicker's menu below its row, with a checkbox per choice | Focus | opening via enter or space key | +| widgets/form.md:1252 | ADD | picked radio and checked box paint accent; unpicked radio paints secondary | Focus | UNMARKED ADD. Fixture is "Select dropdown open / Multi dropdown open"; the open flag is NodeState set only by Enter/Space (or click), so an open dropdown first exists at Focus. | +| widgets/form.md:1253 | ADD | dropdown choices indent four cells under their field | Focus | UNMARKED ADD. Fixture is "Select dropdown open"; needs an open dropdown (Enter/Space). | +| widgets/form.md:1254 | M | ASCII cursor is > and number arrows are < and > | Focus | focused cursor under ASCII | +| widgets/form.md:1255 | ADD | ASCII checkbox and radio marks are [x] [ ] and (*) ( ) | Focus | UNMARKED ADD. Fixtures are "Select dropdown open, ASCII" and "Multi dropdown open, ASCII"; need an open dropdown. | +| widgets/form.md:1258 | M | focused failing field keeps the cursor and paints its label bold error | Focus | focused appearance | +| widgets/form.md:1279 | ADD | form draws the visible rows of a partly visible field at the viewport edge, with no mark | Scroll | UNMARKED ADD. Fixture "Overflow, stacked" is at offset 3 with the cursor on Theme; non-zero NodeState offset needs cursor movement plus chase. | +| widgets/form.md:1280 | M | form chase changes the offset by the fewest rows that show the whole field | Scroll | caps focus+scroll; latest-landing -> Scroll. Chase is reveal-on-focus writing the form offset | +| widgets/form.md:1282 | M | form scrolls content taller than its proposed height inside the window | Scroll | BORDERLINE -> Scroll. The Overflow 40x4 fixture is at offset 2 with the cursor on Theme; the offset is NodeState and non-zero only after cursor movement plus chase. | +| widgets/form.md:1283 | M | up on the first row and down on the last row are not consumed | Focus | key routing at bounds | +| widgets/form.md:1284 | M | enter or space on a select or multi row opens Picker's menu | Focus | Enter and Space key routing | +| widgets/form.md:1285 | M | keys while a dropdown is open are handled by Picker's menu | Focus | key routing to open menu | +| widgets/form.md:1286 | M | focus loss closes an open dropdown without committing | Focus | focus loss | +| widgets/form.md:1287 | M | clicking a row sets the cursor and closes a dropdown open on another row | Pointer | BORDERLINE (focus) resolved: pointer is the trigger, so Pointer (latest anyway). | +| widgets/form.md:1288 | M | clicks in an open dropdown are handled by Picker's menu | Pointer | Mouse routing into an open menu. | +| widgets/form.md:1289 | ADD | form height is the content rows plus the rows of an open dropdown | Focus | UNMARKED ADD. Height grows by the rows of an OPEN dropdown; opening is Enter/Space. | +| widgets/form.md:1290 | M | wheel scrolls the viewport without moving the cursor | Scroll | caps pointer+scroll; latest-landing -> Scroll. Wheel changes the form offset | +| widgets/form.md:1291 | M | form leaves clicks and drags on its scrollbar unconsumed | Pointer | Negative click/drag assertion on scrollbar. | +| widgets/form.md:1292 | M | form cursor starts on the first row and keeps its row across focus changes | Focus | cursor across focus changes | +| widgets/form.md:1293 | M | form ends text editing on focus loss | Focus | focus loss | +| widgets/image.md:394 | M | image takes no focus, keys or pointer input in 1.0; apps wrap it in a Button | Pointer | caps focus+pointer; latest-landing -> Pointer. Explicit pointer-input negative assertion. | +| widgets/key-legend.md:261 | ADD | legend shows all five entries at 60 columns | Focus | UNMARKED ADD. KeyLegend() takes no arguments: its entries exist only as the focused responder chain's declared key bindings (key-legend.md API, state model: bindings; slice 04), so no legend with entries can render before Focus. (If tests may inject a bindings environment value without Focus, this row stays plain.) | +| widgets/key-legend.md:262 | ADD | plain legend keys are bold secondary and descriptions are secondary | Focus | UNMARKED ADD. KeyLegend() takes no arguments: its entries exist only as the focused responder chain's declared key bindings (key-legend.md API, state model: bindings; slice 04), so no legend with entries can render before Focus. (If tests may inject a bindings environment value without Focus, this row stays plain.) | +| widgets/key-legend.md:263 | ADD | keycaps legend fills each key on a keycap | Focus | UNMARKED ADD. KeyLegend() takes no arguments: its entries exist only as the focused responder chain's declared key bindings (key-legend.md API, state model: bindings; slice 04), so no legend with entries can render before Focus. (If tests may inject a bindings environment value without Focus, this row stays plain.) | +| widgets/key-legend.md:264 | ADD | narrow legend drops move and search at 40 columns | Focus | UNMARKED ADD. KeyLegend() takes no arguments: its entries exist only as the focused responder chain's declared key bindings (key-legend.md API, state model: bindings; slice 04), so no legend with entries can render before Focus. (If tests may inject a bindings environment value without Focus, this row stays plain.) | +| widgets/key-legend.md:265 | ADD | narrow legend drops whole entries lowest priority first | Focus | UNMARKED ADD. KeyLegend() takes no arguments: its entries exist only as the focused responder chain's declared key bindings (key-legend.md API, state model: bindings; slice 04), so no legend with entries can render before Focus. (If tests may inject a bindings environment value without Focus, this row stays plain.) | +| widgets/key-legend.md:266 | ADD | legend at 20 columns keeps only change and close | Focus | UNMARKED ADD. KeyLegend() takes no arguments: its entries exist only as the focused responder chain's declared key bindings (key-legend.md API, state model: bindings; slice 04), so no legend with entries can render before Focus. (If tests may inject a bindings environment value without Focus, this row stays plain.) | +| widgets/key-legend.md:267 | ADD | legend never cuts an entry in half | Focus | UNMARKED ADD. KeyLegend() takes no arguments: its entries exist only as the focused responder chain's declared key bindings (key-legend.md API, state model: bindings; slice 04), so no legend with entries can render before Focus. (If tests may inject a bindings environment value without Focus, this row stays plain.) | +| widgets/key-legend.md:268 | ADD | legend drops equal-priority entries as a unit | Focus | UNMARKED ADD. KeyLegend() takes no arguments: its entries exist only as the focused responder chain's declared key bindings (key-legend.md API, state model: bindings; slice 04), so no legend with entries can render before Focus. (If tests may inject a bindings environment value without Focus, this row stays plain.) | +| widgets/key-legend.md:269 | ADD | legend draws nothing below 20 cells | Focus | UNMARKED ADD. KeyLegend() takes no arguments: its entries exist only as the focused responder chain's declared key bindings (key-legend.md API, state model: bindings; slice 04), so no legend with entries can render before Focus. (If tests may inject a bindings environment value without Focus, this row stays plain.) | +| widgets/key-legend.md:270 | ADD | legend entries are one row and drop whole by priority instead of wrapping or truncating | Focus | UNMARKED ADD. KeyLegend() takes no arguments: its entries exist only as the focused responder chain's declared key bindings (key-legend.md API, state model: bindings; slice 04), so no legend with entries can render before Focus. (If tests may inject a bindings environment value without Focus, this row stays plain.) | +| widgets/key-legend.md:271 | ADD | NO_COLOR legend keys stay bold with no brackets or reverse video | Focus | UNMARKED ADD. KeyLegend() takes no arguments: its entries exist only as the focused responder chain's declared key bindings (key-legend.md API, state model: bindings; slice 04), so no legend with entries can render before Focus. (If tests may inject a bindings environment value without Focus, this row stays plain.) | +| widgets/key-legend.md:272 | ADD | over-maximum legend keeps its ideal width and leading alignment | Focus | UNMARKED ADD. KeyLegend() takes no arguments: its entries exist only as the focused responder chain's declared key bindings (key-legend.md API, state model: bindings; slice 04), so no legend with entries can render before Focus. (If tests may inject a bindings environment value without Focus, this row stays plain.) | +| widgets/key-legend.md:273 | ADD | ascii legend spells keys out and drops search at 60 columns | Focus | UNMARKED ADD. KeyLegend() takes no arguments: its entries exist only as the focused responder chain's declared key bindings (key-legend.md API, state model: bindings; slice 04), so no legend with entries can render before Focus. (If tests may inject a bindings environment value without Focus, this row stays plain.) | +| widgets/key-legend.md:274 | ADD | ascii legend separates entries with a pipe | Focus | UNMARKED ADD. KeyLegend() takes no arguments: its entries exist only as the focused responder chain's declared key bindings (key-legend.md API, state model: bindings; slice 04), so no legend with entries can render before Focus. (If tests may inject a bindings environment value without Focus, this row stays plain.) | +| widgets/key-legend.md:275 | ADD | NO_COLOR legend falls back to the plain look for keycaps | Focus | UNMARKED ADD. KeyLegend() takes no arguments: its entries exist only as the focused responder chain's declared key bindings (key-legend.md API, state model: bindings; slice 04), so no legend with entries can render before Focus. (If tests may inject a bindings environment value without Focus, this row stays plain.) | +| widgets/key-legend.md:276 | M | legend entries come from the focused responder's bindings | Focus | focused responder bindings (KeyLegend) | +| widgets/key-legend.md:277 | M | legend takes no focus and consumes no keys | Focus | negative focus and key assertion | +| widgets/key-legend.md:278 | ADD | light sixteen colour legend falls back to the plain look for keycaps | Focus | UNMARKED ADD. KeyLegend() takes no arguments: its entries exist only as the focused responder chain's declared key bindings (key-legend.md API, state model: bindings; slice 04), so no legend with entries can render before Focus. (If tests may inject a bindings environment value without Focus, this row stays plain.) | +| widgets/labeled-content.md:379 | M | labeled content holds no node state and lets every key and pointer event bubble | Pointer | caps focus+pointer; latest-landing -> Pointer. Pointer events bubbling is a Pointer routing assertion. | +| widgets/list.md:322 | M | arrow selection auto-commits through the binding without enter | Focus | Down key routing | +| widgets/list.md:323 | M | down with no selection selects the first visible selectable row | Focus | Down key routing | +| widgets/list.md:324 | M | selection traversal skips section headers by construction | Focus | Down key traversal | +| widgets/list.md:325 | M | selection stays visible when moved past the viewport edge | Scroll | caps focus+scroll; latest-landing -> Scroll. Key-driven selection move scrolls the viewport to keep it visible | +| widgets/list.md:326 | M | wheel scrolling moves viewport without changing selection | Scroll | caps pointer+scroll; latest-landing -> Scroll. Wheel changes NodeState scroll offset | +| widgets/list.md:327 | M | boundary wheel events pass to ancestors unconsumed | Scroll | caps pointer+scroll; latest-landing -> Scroll. Boundary wheel bubbling is interactive scroll routing | +| widgets/list.md:329 | M | collapsing the selected rows section hides selection without mutating the binding | Focus | Left key routing | +| widgets/list.md:330 | M | arrows from a hidden selection move to the nearest visible selectable row | Focus | Down key routing | +| widgets/list.md:331 | M | left collapses and right expands the selections section via its binding | Focus | Left and Right key routing | +| widgets/list.md:332 | M | re-expansion renders the preserved selection without auto-scrolling | Scroll | Negative assertion: reveal must not fire; only meaningful once reveal/scroll-to-selection exists | +| widgets/list.md:333 | M | scroll offset clamps when app shrinks data | Scroll | BORDERLINE -> Scroll. List offset is NodeState; the clamp is only observable once input has made it non-zero. | +| widgets/list.md:335 | M | absent selection binding reserves no gutter and arrows scroll | Scroll | caps focus+scroll; latest-landing -> Scroll. Arrow keys scroll the list when no selection binding exists | +| widgets/list.md:336 | M | enter activates the selected element | Focus | Enter key routing | +| widgets/list.md:337 | M | double click selects then activates | Pointer | Mouse double-click. | +| widgets/list.md:338 | M | click on a data row selects it through the binding | Pointer | Mouse click. | +| widgets/list.md:339 | M | click on a collapsible header toggles expansion through its binding | Pointer | Mouse click on header. | +| widgets/list.md:342 | M | unfocused selection renders the inactive tint | Focus | focus-dependent selection style | +| widgets/list.md:347 | M | selection bar is accent when focused and secondary when unfocused | Focus | focus-dependent bar style | +| widgets/list.md:349 | ADD | partly visible items draw their visible rows at the top and bottom edges without a mark | Scroll | UNMARKED ADD. "states: wrapped" is at offset 2 rows (first item shows only its last row); a non-zero NodeState offset is only reachable by scroll input or reveal. | +| widgets/list.md:350 | M | scroll offset thumb and paging count rows not items | Scroll | caps focus+scroll; latest-landing -> Scroll. Paging moves the offset by rows; the thumb clause alone is geometry | +| widgets/list.md:351 | M | reveal scrolls the fewest rows that show the whole selected item | Scroll | caps focus+scroll; latest-landing -> Scroll. Reveal-on-selection writes the offset | +| widgets/list.md:352 | M | item taller than the viewport top-aligns when revealed | Scroll | BORDERLINE (focus) resolved: reveal trigger is irrelevant; offset change makes it Scroll either way. | +| widgets/list.md:353 | M | width change re-measures item heights and clamps the offset | Scroll | BORDERLINE -> Scroll. Re-measure is layout, but the offset clamp needs a prior non-zero NodeState offset. | +| widgets/list.md:356 | M | page down moves selection to the item one viewport of rows below | Focus | EXCEPTION: rule gives Scroll (focus+scroll). The assertion is only the selection TARGET: the item containing the row V rows below the selected item's first row, with V the static viewport height. Neither the offset nor input scrolling enters it; the offset effect is covered by list.md:351 (reveal) and list.md:350 (paging counts rows). Testable at Focus. | +| widgets/list.md:359 | M | row under the pointer takes the hover fill; a selected row keeps selection.fill under the pointer | Pointer | Hover state from pointer position. | +| widgets/list.md:360 | M | rows request pointer motion only while the list is live in the graph | Pointer | Pointer-motion request lifecycle. | +| widgets/list.md:361 | M | .rowHover(.disabled) removes the hover fill and the motion request | Pointer | Hover and motion-request disable. | +| widgets/list.md:362 | M | pointer motion moves the derived hovered row without changing selection or scroll offset | Pointer | Pointer move event handling. | +| widgets/list.md:363 | M | the hovered row clears on focus loss and when motion tracking stops | Pointer | caps focus+pointer; latest-landing -> Pointer. Hover state lifecycle. | +| widgets/list.md:364 | M | slots, NO_COLOR and 16-colour show hover as an underline on the row label | Pointer | Hover degradation rendering requires a hover state. | +| widgets/markdown.md:506 | M | markdown takes no focus, keys or pointer input | Pointer | caps focus+pointer; latest-landing -> Pointer. Explicit pointer-input negative assertion. | +| widgets/markdown.md:515 | M | markdown does not reveal streamed source in 1.0 | Clock | BORDERLINE -> Clock. A negative assertion about time-driven reveal: it needs the clock machinery to advance time and show nothing progressive. (The immediate re-render itself is covered by markdown.md:514, plain.) | +| widgets/multi-picker.md:553 | M | multipicker menu Space toggles the cursor option and keeps the list open | Focus | Space key routing | +| widgets/multi-picker.md:554 | M | multipicker menu Enter bubbles in place | Focus | Enter key routing | +| widgets/multi-picker.md:555 | M | multipicker menu Enter never toggles the cursor option | Focus | Enter key routing | +| widgets/multi-picker.md:556 | M | multipicker menu digit key toggles the Nth option and keeps the list open | Focus | digit key routing | +| widgets/multi-picker.md:557 | M | multipicker menu digit beyond the row count bubbles | Focus | digit key bubbling | +| widgets/multi-picker.md:559 | M | multipicker menu click toggles the option and keeps the list open | Pointer | Mouse click on option row. | +| widgets/multi-picker.md:560 | M | multipicker menu wheel moves the cursor and is consumed at list edges | Pointer | BORDERLINE (scroll) -> Pointer. The wheel moves the cursor, not the offset, and the wheel is always consumed (never bubbles), so no Scroll machinery is asserted. | +| widgets/multi-picker.md:561 | M | multipicker menu paste reaches the draft only while editing | Focus | paste input routed through focus | +| widgets/multi-picker.md:562 | M | multipicker draws no hover and requests no pointer motion | Pointer | Negative pointer-motion/hover assertion. | +| widgets/multi-picker.md:566 | M | multipicker menu scrolls in rows under a fixed height | Scroll | BORDERLINE -> Scroll. Menu scroll offset is NodeState; "scrolls in rows" (40x3, partly visible option) needs a non-zero offset. | +| widgets/multi-picker.md:573 | M | multipicker Other row starts editing on Space and a click | Pointer | caps focus+pointer; latest-landing -> Pointer. Mouse click on custom row. | +| widgets/multi-picker.md:574 | M | multipicker custom draft Enter adds the accepted value to the binding | Focus | Enter key routing while editing | +| widgets/multi-picker.md:575 | M | multipicker custom draft rejected by customOption keeps editing | Focus | Enter key routing while editing | +| widgets/multi-picker.md:577 | M | multipicker never inserts a duplicate typed value | Focus | Enter key routing while editing | +| widgets/multi-picker.md:578 | ADD | multipicker unchecking a typed value removes it from the binding and its row | Focus | UNMARKED ADD. Unchecking a typed value is a toggle (Space or digit key) on the typed row. | +| widgets/multi-picker.md:579 | M | multipicker draft line stays one row and scrolls around the caret | Focus | BORDERLINE (scroll) -> Focus. The one-row draft line follows the caret while editing; it is editing machinery (keys), with no viewport, wheel, bubbling or indicator involved. | +| widgets/multi-picker.md:580 | M | multipicker cursor stays on the unchecked Other row after an accepted draft | Focus | Enter key routing and cursor | +| widgets/multi-picker.md:585 | M | multipicker radio group Space toggles the cursor chip | Focus | Space key routing | +| widgets/multi-picker.md:586 | M | multipicker radio group Enter bubbles | Focus | Enter key routing | +| widgets/multi-picker.md:587 | M | multipicker radio group Left and Right move the cursor and wrap across rows | Focus | Left and Right key routing | +| widgets/multi-picker.md:588 | M | multipicker radio group click toggles the chip | Pointer | Mouse click on chip. | +| widgets/multi-picker.md:591 | M | multipicker radio group leaves Up and Down unconsumed | Focus | Up and Down key routing | +| widgets/multi-picker.md:596 | ADD | multipicker host row draws the title as the host's row label and no title row in the open list | Focus | UNMARKED ADD. Same as picker.md:1386 for MultiPicker host row. | +| widgets/multi-picker.md:597 | M | multipicker host row Enter and Space open the list below the row | Focus | Enter and Space key routing | +| widgets/multi-picker.md:598 | M | multipicker host row Space toggles and keeps the list open | Focus | Space key routing | +| widgets/multi-picker.md:599 | M | multipicker host row Enter closes the list without writing | Focus | Enter key routing | +| widgets/multi-picker.md:600 | M | multipicker host row Escape closes the list and toggles already written stay | Focus | Escape key routing | +| widgets/multi-picker.md:601 | M | multipicker host row focus loss closes the list | Focus | focus loss | +| widgets/multi-picker.md:606 | M | multipicker host row digit key toggles the Nth option and keeps the list open | Focus | digit key routing | +| widgets/navigation-split-view.md:349 | ADD | tier change moves focus to a rendered column without mutating business bindings | Focus | UNMARKED ADD. Tier change moves focus to a rendered column (focus model). Doc has no participation entry. | +| widgets/navigation-split-view.md:350 | ADD | container consumes no keys and installs no pointer handlers | Pointer | UNMARKED ADD. Negative over the key table and mouse table; the pointer half needs hit-testing machinery, so the later capability (Pointer). | +| widgets/navigation-split-view.md:351 | ADD | divider drag changes the side pane on its first press | Pointer | UNMARKED ADD. Divider drag first press (mouse table: leading divider). | +| widgets/navigation-split-view.md:352 | ADD | sidebar selection dims to inactive when its column loses focus | Focus | UNMARKED ADD. Sidebar selection tint swaps to inactive on focus loss (focused appearance). | +| widgets/outline.md:470 | M | scrolls itself without a ScrollView wrapper | Scroll | BORDERLINE -> Scroll. The Scrolled fixture is at offset 7 (selection kept visible); the claim is only verifiable with a non-zero NodeState offset. | +| widgets/outline.md:473 | ADD | thumb length and row follow the scrolled formulas | Scroll | UNMARKED ADD. "thumb ... row follow the scrolled formulas": Scrolled fixture is at offset 7 (NodeState), so the thumb row needs scroll/reveal. | +| widgets/outline.md:488 | M | uses the tree keys and mouse in path mode | Pointer | caps focus+pointer; latest-landing -> Pointer. Mouse behaviour in path mode. | +| widgets/outline.md:492 | M | moves selection with the up and down arrows | Focus | arrow key routing | +| widgets/outline.md:493 | M | saturates up and down at the last and first row and keeps them consumed | Focus | arrow key routing and consumption | +| widgets/outline.md:494 | M | selects the first visible row on down when nothing is selected | Focus | Down key routing | +| widgets/outline.md:495 | M | moves selection with Home, End, PgUp and PgDn | Focus | EXCEPTION: rule gives Scroll (focus+scroll). Home/End/PgUp/PgDn only move the selection (PgUp/PgDn by V static viewport rows); the offset effect is covered by outline reveal (gap) and outline.md:470. Testable at Focus. | +| widgets/outline.md:496 | M | expands, collapses and moves selection with the left and right arrows | Focus | Left and Right key routing | +| widgets/outline.md:497 | M | toggles a branch on Space and lets Space on a leaf bubble | Focus | Space key routing | +| widgets/outline.md:498 | M | calls onActivate with the selected node on Enter | Focus | Enter key routing | +| widgets/outline.md:499 | M | selects a row on click and toggles a branch on expander click | Pointer | Mouse click and expander click. | +| widgets/outline.md:500 | M | selects the row and calls onActivate on double-click | Pointer | Mouse double-click. | +| widgets/outline.md:501 | M | scrolls the rows on wheel-up and wheel-down without moving the selection | Scroll | caps pointer+scroll; latest-landing -> Scroll. Wheel changes NodeState scroll offset | +| widgets/outline.md:502 | M | leaves a wheel event at the scroll boundary unconsumed | Scroll | caps pointer+scroll; latest-landing -> Scroll. Boundary wheel routing is interactive scroll | +| widgets/picker.md:1284 | M | picker first enabled cycle from an absent value selects the first supplied option | Focus | Right key routing | +| widgets/picker.md:1285 | M | picker advances on focused right and down keys | Focus | focused key routing | +| widgets/picker.md:1286 | M | picker moves backward on focused left and up keys | Focus | focused key routing | +| widgets/picker.md:1287 | M | picker bubbles an unavailable endpoint cycle without changing selection | Focus | key bubbling at endpoints | +| widgets/picker.md:1288 | M | picker focuses before a value-slot click advances selection | Pointer | caps focus+pointer; latest-landing -> Pointer. Click-to-focus then click advance (mouse table). | +| widgets/picker.md:1289 | M | picker leaves a last-option value-slot click unconsumed | Pointer | Negative click consumption. | +| widgets/picker.md:1290 | M | disabled picker is unfocusable and leaves input unconsumed | Pointer | caps focus+pointer; latest-landing -> Pointer. Mouse table value-slot click on disabled picker is unconsumed. | +| widgets/picker.md:1296 | M | picker draws no hover and requests no pointer motion in any presentation | Pointer | Negative hover/pointer-motion assertion. | +| widgets/picker.md:1313 | M | picker radio group Right and Left advance in order and wrap across rows | Focus | Left and Right key routing | +| widgets/picker.md:1314 | M | picker radio group leaves Up and Down unconsumed | Focus | Up and Down key routing | +| widgets/picker.md:1315 | M | picker radio group click on a chip sets selection and consumes the click | Pointer | Mouse click on chip. | +| widgets/picker.md:1316 | M | picker radio group shows the cursor prefix only while focused | Focus | focus-gated cursor | +| widgets/picker.md:1321 | M | picker menu lists options in place with a cursor separate from the chosen marker | Focus | menu cursor shown with focus | +| widgets/picker.md:1322 | M | picker menu clamps Up and Down at the list edges without wrapping | Focus | Up and Down key routing | +| widgets/picker.md:1323 | M | picker menu Enter commits the cursor option through the binding | Focus | Enter key routing | +| widgets/picker.md:1325 | M | picker menu wheel moves the cursor and is consumed at list edges | Pointer | BORDERLINE (scroll) -> Pointer. Same as multi-picker.md:560. | +| widgets/picker.md:1326 | M | picker menu paste while editing inserts into the draft with line breaks as spaces | Focus | paste input routed through focus | +| widgets/picker.md:1327 | M | picker menu click on an option row moves the cursor then commits it | Pointer | Mouse click on option row. | +| widgets/picker.md:1333 | M | picker menu scrolls in rows and shows partial items at both edges | Scroll | BORDERLINE -> Scroll. Partial items at BOTH edges needs a non-zero NodeState menu offset. | +| widgets/picker.md:1334 | M | picker menu reveals the whole cursor item by the fewest rows | Scroll | caps focus+scroll; latest-landing -> Scroll. Reveal-on-cursor-move writes the menu offset | +| widgets/picker.md:1335 | M | picker menu PgUp and PgDn move the cursor by viewport rows | Focus | EXCEPTION: rule gives Scroll (focus+scroll). PgUp/PgDn move the CURSOR by the options spanning V viewport rows (V static from layout); the offset effect is covered by picker.md:1334. Testable at Focus. | +| widgets/picker.md:1341 | M | picker menu Enter on the custom row starts editing | Focus | Enter key routing | +| widgets/picker.md:1342 | M | picker menu editing keys edit the draft and Enter commits it through customOption | Focus | editing key routing | +| widgets/picker.md:1343 | M | picker menu keeps editing when customOption rejects the draft | Focus | Enter key routing while editing | +| widgets/picker.md:1344 | M | picker menu Escape ends editing and clears the draft while a committed value stays | Focus | Escape key routing | +| widgets/picker.md:1345 | M | picker menu keeps the custom row chosen with its committed value after Enter | Focus | Enter key routing | +| widgets/picker.md:1346 | M | picker menu draft line stays one row and scrolls around the caret | Focus | BORDERLINE (scroll) -> Focus. Same as multi-picker.md:579. | +| widgets/picker.md:1358 | M | picker segmented active segment draws solid(accent) with no underline in colour tiers and underlines only under NO_COLOR while focused | Focus | focused appearance | +| widgets/picker.md:1359 | M | picker segmented unselected segment labels brighten from secondary to primary while the picker is focused | Focus | focused appearance | +| widgets/picker.md:1362 | M | picker segmented Right and Left wrap and are consumed | Focus | Left and Right key routing | +| widgets/picker.md:1363 | M | picker segmented click on a segment sets selection and consumes the click | Pointer | Mouse click on segment. | +| widgets/picker.md:1367 | M | picker menu digit key commits the Nth option | Focus | digit key routing | +| widgets/picker.md:1368 | M | picker menu digit beyond the option count bubbles | Focus | digit key bubbling | +| widgets/picker.md:1370 | M | picker menu digit is typed text while editing the custom row | Focus | typing keys while editing | +| widgets/picker.md:1380 | M | picker host row Enter and Space open the list below the row | Focus | Enter and Space key routing | +| widgets/picker.md:1381 | M | picker host row Up, Down, Left, and Right bubble while closed | Focus | arrow key bubbling | +| widgets/picker.md:1382 | M | picker host row Enter and Space commit the cursor option and close | Focus | Enter and Space key routing | +| widgets/picker.md:1383 | M | picker host row Escape closes the list without committing | Focus | Escape key routing | +| widgets/picker.md:1384 | M | picker host row focus loss closes the list without committing | Focus | focus loss | +| widgets/picker.md:1385 | M | picker host row digit key commits the Nth option and closes | Focus | digit key routing | +| widgets/picker.md:1386 | ADD | picker host row draws the title as the host's row label and no title row in the open list | Focus | UNMARKED ADD. Half of the assertion ("no title row in the open list") needs the host row opened with Enter/Space. | +| widgets/progress-view.md:477 | M | indeterminate label wraps beside the spinner | none | BORDERLINE -> none. Label wrap beside a fixed-width spinner column is layout at one instant; the frame never changes the label column. Assumes tests can supply a clock instant through the environment key before Clock lands (see Disagreements D9). | +| widgets/progress-view.md:481 | M | indeterminate label starts two cells in for every frame set | Clock | Must hold across every frame of every set as the spinner turns; frames are selected from the supplied clock | +| widgets/progress-view.md:487 | M | built-in spinner sets run at their own intervals | Clock | Frame interval is a time property of each set | +| widgets/progress-view.md:489 | M | spinner frame derives from the supplied clock and stores no state | Clock | Frame is (now / interval) mod frames.count from the injected clock; needs Clock machinery | +| widgets/progress-view.md:490 | M | indeterminate view declares a tick requirement at its frame set's interval | Clock | Tick requirement declaration is the Clock machinery's input | +| widgets/progress-view.md:491 | M | determinate view declares no tick requirement | Clock | Negative assertion about tick requirements; only meaningful once tick declaration exists | +| widgets/progress-view.md:492 | M | nerd font set drawn as orbit declares orbit's interval | Clock | Tick requirement uses the drawn set's interval | +| widgets/scroll-view.md:510 | M | vertical offset indicator and paging count rows not items | Scroll | caps focus+scroll; latest-landing -> Scroll. PgUp and PgDn change the offset by viewport rows; paging is interactive offset change | +| widgets/scroll-view.md:512 | M | reveal scrolls the fewest rows that show a whole item and top-aligns a taller one | Scroll | BORDERLINE (focus) resolved: reveal is Scroll whatever triggers it. | +| widgets/scroll-view.md:516 | M | focused arrow keys move one cell on their enabled axis | Scroll | caps focus+scroll; latest-landing -> Scroll. Arrow keys change the offset; needs key-driven scroll | +| widgets/scroll-view.md:517 | M | page home and end keys clamp to the viewport bounds | Scroll | caps focus+scroll; latest-landing -> Scroll. Paging and Home/End change the offset by key | +| widgets/scroll-view.md:518 | M | keyboard input bubbles from a focusable scroll view at its boundary | Scroll | caps focus+scroll; latest-landing -> Scroll. Boundary bubbling to enclosing viewport is interactive routing | +| widgets/scroll-view.md:520 | M | focused scroll view delegates a foreground-only box border while retaining key ownership and its edge-riding indicator | Focus | focused appearance and key ownership | +| widgets/scroll-view.md:521 | M | focused fitting scroll view shows no local treatment without an indicator | Focus | focused appearance | +| widgets/scroll-view.md:522 | M | focused viewport paints indicator thumbs in focus (bold default foreground) without restyling content | Focus | focused appearance | +| widgets/scroll-view.md:523 | M | owned focus identity registers and withdraws as layout settles | Focus | focus registration | +| widgets/scroll-view.md:524 | M | owned focus identity requires an offset binding | Focus | focus eligibility | +| widgets/scroll-view.md:525 | M | key repeats scroll exactly like presses | Scroll | caps focus+scroll; latest-landing -> Scroll. Scroll keys must move the offset; repeat only matters once keys scroll | +| widgets/scroll-view.md:526 | M | unconsumed scroll keys bubble to a focus-within ancestor viewport | Scroll | caps focus+scroll; latest-landing -> Scroll. Unconsumed scroll keys reaching an ancestor viewport is boundary bubbling | +| widgets/scroll-view.md:530 | M | ascii degradation preserves scroll geometry and boundary routing | Scroll | BORDERLINE (focus) resolved: the rule is about scroll geometry and wheel/key boundary routing; Scroll either way. | +| widgets/scroll-view.md:531 | M | scroll view exposes no zoom or smooth animation transition | Clock | BORDERLINE (clock) -> Clock. Offsets are written synchronously by input (Scroll), but "no zoom or smooth animation transition" is a negative assertion about motion over time; it needs the Clock machinery to prove no transition or tick requirement exists. Latest-landing rule gives Clock; not an exception. | +| widgets/scroll-view.md:538 | M | unbound scroll view stays at the origin | Scroll | BORDERLINE -> Scroll. The claim bites only when key or wheel input reaches an unbound viewport and the offset is held at the origin (negative assertion, needs Scroll input). | +| widgets/scroll-view.md:539 | M | wheel moves one row or column through the offset binding | Scroll | caps pointer+scroll; latest-landing -> Scroll. Wheel changes the offset | +| widgets/scroll-view.md:540 | M | wheel at a boundary or without a binding bubbles to an enclosing viewport | Scroll | caps pointer+scroll; latest-landing -> Scroll. Wheel boundary bubbling to enclosing viewport | +| widgets/scroll-view.md:541 | M | wheel on an axis the viewport does not scroll bubbles | Scroll | caps pointer+scroll; latest-landing -> Scroll. Wheel routing decision per axis; bubbling is interactive | +| widgets/scroll-view.md:542 | M | clicks and drags on indicators and the corner return ignored | Pointer | Negative click/drag assertion on indicators and corner. | +| widgets/scroll-view.md:543 | M | focus moving into hidden content reveals it through the offset binding | Scroll | caps focus+scroll; latest-landing -> Scroll. Reveal-on-focus writes the offset | +| widgets/section.md:186 | M | header is not focusable and not selectable | Focus | negative focus assertion | +| widgets/section.md:191 | M | click on a collapsible header toggles expansion through the binding | Pointer | Mouse click on header. | +| widgets/section.md:192 | M | click on a noninteractive header is not consumed | Pointer | Negative click consumption. | +| widgets/section.md:193 | M | section forwards row focus input and bindings without interception | Pointer | caps focus+pointer; latest-landing -> Pointer. Forwarding of pointer input to grouped rows (mouse table). | +| widgets/split-view.md:632 | M | split view restores a hidden focused descendant only when focus remains clear | Focus | focus return | +| widgets/split-view.md:633 | M | split view moves focus to the nearest visible pane when collapsing the focused pane | Focus | focus removal handling | +| widgets/split-view.md:634 | M | focused divider arrow keys move the boundary on first press with flexible slack | Focus | arrow key routing to focused divider | +| widgets/split-view.md:635 | M | horizontal divider handle arrow keys resize only its adjacent controlled pane pair | Focus | arrow key routing | +| widgets/split-view.md:636 | M | vertical divider handle arrow keys resize only its adjacent controlled pane pair | Focus | arrow key routing | +| widgets/split-view.md:637 | M | split view leaves tab and escape available to application routing | Focus | Tab and Esc key routing | +| widgets/split-view.md:638 | M | dragging a divider updates adjacent bound sizes with integer clamping | Pointer | Drag gesture. | +| widgets/split-view.md:639 | M | active split drag survives reconciliation for a stable pane pair | Pointer | Pointer drag state (state model: pointer drag). | +| widgets/split-view.md:640 | M | split view cancels a stale drag when its pane pair disappears | Pointer | Pointer drag cancellation (mouse table). | +| widgets/split-view.md:648 | M | split view transposes divider hit geometry for vertical axis | Pointer | Asserts hit geometry of dividers. | +| widgets/split-view.md:649 | M | focused divider styling overlays only a bold focus foreground with no background | Focus | focused appearance | +| widgets/split-view.md:650 | M | split view moves a focused divider five cells with shift arrow keys | Focus | Shift-arrow key routing | +| widgets/split-view.md:651 | M | split view resets a focused divider pair to defaultIdeal with equals | Focus | = key routing | +| widgets/split-view.md:652 | M | split view resets a divider pair to defaultIdeal on double-click | Pointer | Mouse double-click. | +| widgets/split-view.md:653 | M | divider grab zone hit-tests one cell to each side without drawing extra cells | Pointer | Hit testing of grab zone. | +| widgets/split-view.md:660 | M | focused divider draws the heavy line in the focus style | Focus | focused appearance | +| widgets/split-view.md:661 | M | focused divider junctions render heavy junction glyphs | Focus | focused appearance | +| widgets/split-view.md:662 | M | ascii fallback draws plus at junctions and bolds a focused divider | Focus | focused appearance under ASCII | +| widgets/stepper.md:371 | M | stepper increments its binding from an interior value | Focus | Up key routing | +| widgets/stepper.md:372 | M | stepper decrements its binding from an interior value | Focus | Down key routing | +| widgets/stepper.md:373 | M | stepper leaves an increment key unconsumed at maximum | Focus | key consumption | +| widgets/stepper.md:374 | M | stepper leaves a decrement key unconsumed at minimum | Focus | key consumption | +| widgets/stepper.md:375 | M | stepper clamps a non-divisible final increment exactly to maximum | Focus | Up key routing | +| widgets/stepper.md:376 | M | stepper clamps a non-divisible final decrement exactly to minimum | Focus | Down key routing | +| widgets/stepper.md:378 | M | stepper clamps an out-of-range value through its binding on user activation | Focus | Up or Down key routing | +| widgets/stepper.md:379 | M | stepper applies a positive non-unit stride through its binding | Focus | Up key routing | +| widgets/stepper.md:381 | M | stepper keeps both arrows visible while unfocused | Focus | unfocused appearance | +| widgets/stepper.md:382 | M | stepper draws an arrow at its limit in the disabled role and keeps its place | Focus | focused-at-limit appearance | +| widgets/stepper.md:384 | M | stepper marks focus with the host row's cursor and focus-role label, not the arrows | Focus | focus appearance | +| widgets/stepper.md:385 | M | stepper focuses before increment-arrow activation | Pointer | caps focus+pointer; latest-landing -> Pointer. Click-to-focus on arrow click (mouse table). | +| widgets/stepper.md:386 | M | stepper leaves endpoint-arrow clicks unconsumed | Pointer | Negative click consumption at endpoints. | +| widgets/stepper.md:387 | M | disabled stepper renders every part in the disabled role and leaves keys and clicks unconsumed | Pointer | caps focus+pointer; latest-landing -> Pointer. Negative click consumption when disabled. | +| widgets/tab-view.md:436 | M | left on the first tab wraps the selection to the last tab | Focus | Left key routing | +| widgets/tab-view.md:437 | M | right on the last tab wraps the selection to the first tab | Focus | Right key routing | +| widgets/tab-view.md:438 | M | left and right write the selection only through the binding | Focus | Left and Right key routing | +| widgets/tab-view.md:440 | M | clicking a tab chip writes its value through the binding | Pointer | Mouse click on chip. | +| widgets/tab-view.md:441 | M | clicking the active tab chip leaves the selection unchanged | Pointer | Mouse click on active chip. | +| widgets/tab-view.md:442 | M | a click outside a tab chip has no effect | Pointer | Click hit testing outside chips. | +| widgets/tab-view.md:443 | M | move input is requested only while a hover consumer exists in the subtree; with none, move events are not delivered | Pointer | Pointer-move request and delivery. | +| widgets/tab-view.md:444 | M | move events draw no hover state | Pointer | Negative hover assertion from move events. | +| widgets/tab-view.md:445 | M | focused tab view draws the focus cursor on the bar row only | Focus | focus cursor | +| widgets/tab-view.md:446 | M | nerd font draws the cursor mark as U+F054 | Focus | focus cursor glyph | +| widgets/tab-view.md:447 | M | unfocused tab view draws a blank cursor and the accent pill | Focus | unfocused appearance | +| widgets/tab-view.md:450 | M | active pill is solid(focus) while the tab view is focused and solid(accent) when it is not | Focus | focus-dependent appearance | +| widgets/table.md:756 | M | selection stays visible when moved past the viewport edge | Scroll | caps focus+scroll; latest-landing -> Scroll. Key-driven selection move scrolls the viewport | +| widgets/table.md:757 | M | successive selection moves separated by graph updates keep selection and scroll offset synchronized | Scroll | caps focus+scroll; latest-landing -> Scroll. Repeated key-driven moves must keep the NodeState offset in step with selection | +| widgets/table.md:758 | M | wheel scrolling moves viewport without changing selection | Scroll | caps pointer+scroll; latest-landing -> Scroll. Wheel changes scroll offset | +| widgets/table.md:759 | M | scroll indicator is output-only and has no drag interaction | Pointer | Negative drag assertion on indicator. | +| widgets/table.md:762 | M | scroll offset clamps when app shrinks data | Scroll | BORDERLINE -> Scroll. Table offset is NodeState; clamp observable only after it is non-zero. | +| widgets/table.md:764 | M | header click cycles the sort entry for that column id through binding | Pointer | Mouse click on header cell. | +| widgets/table.md:767 | M | enter activates the selected element | Focus | Enter key routing | +| widgets/table.md:784 | M | table with focus draws its outer ring in the focus role with the heavy set; focus inside draws it at light weight | Focus | focus appearance | +| widgets/table.md:785 | M | ascii focus renders bold on the bordered ring | Focus | focus appearance under ASCII | +| widgets/table.md:788 | M | multi-select click on a data row replaces the selection set and moves the cursor there | Pointer | Mouse click on data row. | +| widgets/table.md:789 | M | mark gutter click toggles membership and moves the cursor | Pointer | Mouse click on gutter. | +| widgets/table.md:790 | M | double-click runs the click effect, then activates the row, in single and multi-select | Pointer | Mouse double-click. | +| widgets/table.md:791 | M | multi-select arrow keys move the cursor and leave membership unchanged | Focus | arrow key routing | +| widgets/table.md:792 | M | multi-select space toggles the cursor row membership and leaves the cursor in place | Focus | Space key routing | +| widgets/table.md:793 | M | multi-select enter activates the cursor row | Focus | Enter key routing | +| widgets/table.md:794 | ADD | multi-select cursor clamps to the data count and clears when data is empty | Focus | UNMARKED ADD. Cursor is NodeState "nil until a cursor key or click"; a non-nil cursor to clamp or clear needs keys, which land first. | +| widgets/table.md:797 | M | ctrl-up enters header mode only when a sort order binding is set | Focus | Ctrl-Up key routing | +| widgets/table.md:798 | M | space in header mode cycles that column's sort entry through none, ascending, descending, removed | Focus | Space key routing in header mode | +| widgets/table.md:799 | M | left and right clamp the header cursor to the first and last column | Focus | Left and Right key routing | +| widgets/table.md:800 | M | down and escape leave header mode, and focus loss clears the header cursor | Focus | key routing and focus loss | +| widgets/table.md:801 | M | header mode draws the header cursor cell in selection fill and dims the row selection | Focus | header mode keyboard state | +| widgets/table.md:814 | M | bar cell takes accent or secondary and the selection fill when derived; under reverse-video selection fill it draws on the terminal background | Focus | accent or secondary depends on focus | +| widgets/table.md:815 | M | row under the pointer takes the hover fill; a selected row keeps selection.fill under the pointer | Pointer | Hover state from pointer position. | +| widgets/table.md:816 | M | hover and stripes coexist: a hovered striped row shows hover over its stripe | Pointer | Hover state. | +| widgets/table.md:817 | M | rows request pointer motion only while the table is live in the graph | Pointer | Pointer-motion request lifecycle. | +| widgets/table.md:818 | M | .rowHover(.disabled) removes the hover fill and the motion request | Pointer | Hover and motion-request disable. | +| widgets/table.md:819 | M | move event sets the hovered row without changing selection or scroll offset | Pointer | Pointer move event handling. | +| widgets/table.md:820 | M | slots, NO_COLOR and 16-colour show hover as an underline on the row label | Pointer | Hover degradation rendering requires hover state. | +| widgets/table.md:821 | M | scroll offset thumb and paging count rows not data rows | Scroll | caps focus+scroll; latest-landing -> Scroll. Paging moves the offset by rows; the thumb clause alone is geometry | +| widgets/table.md:822 | ADD | partly visible data row draws its visible rows without a mark | Scroll | UNMARKED ADD. Partly visible data row at the top edge needs a non-zero NodeState offset. | +| widgets/text-field.md:444 | M | text field continuously writes printable graphemes through its binding | Focus | printable key routing | +| widgets/text-field.md:445 | M | text field normalizes focused paste input into one single-line binding edit | Focus | focused paste input | +| widgets/text-field.md:446 | M | text field routes software keyboard and dictation characters as ordinary printable input | Focus | printable key routing | +| widgets/text-field.md:447 | M | text field clamps its cursor after the app replaces bound text | Focus | BORDERLINE -> Focus. The caret has no non-initial position until a key (or click) places it; keys land first, so Focus. | +| widgets/text-field.md:448 | M | text field clamps its selection anchor after the app replaces bound text | Focus | selection anchor set by keyboard selection | +| widgets/text-field.md:449 | M | text field replaces whole selected graphemes through its binding | Focus | printable key routing over selection | +| widgets/text-field.md:450 | M | text field never positions caret inside CJK emoji or combining graphemes | Focus | Left key routing | +| widgets/text-field.md:451 | M | text field reveals the hardware caret without inserting an ellipsis into editable text | Focus | BORDERLINE (scroll) -> Focus. Hardware caret exists only while focused; the horizontal reveal offset is derived from caret motion, not from any Scroll capability. | +| widgets/text-field.md:456 | M | text field invokes submit without owning or clearing app text | Focus | Enter key routing | +| widgets/text-field.md:457 | M | text field bubbles Enter when has submit handler is false | Focus | Enter key bubbling | +| widgets/text-field.md:458 | M | text field bubbles Tab and Esc for app focus and dismissal policy | Focus | Tab and Esc key bubbling | +| widgets/text-field.md:459 | M | text field deletes whole graphemes on both sides of the caret | Focus | Backspace and Delete key routing | +| widgets/text-field.md:460 | M | text field maps an enabled input-line click to a grapheme boundary | Pointer | BORDERLINE (focus) resolved: click is the trigger, so Pointer (latest anyway). | +| widgets/text-field.md:461 | M | text field requests a focused enabled hardware cursor at its visible caret boundary | Focus | focus-gated cursor | +| widgets/text-field.md:462 | M | text field leaves disabled bindings and routing untouched | Pointer | caps focus+pointer; latest-landing -> Pointer. Mouse table disabled-click negative assertion; key half is Focus. | +| widgets/toggle.md:454 | M | toggle keeps a parent-forced mobile row as one full-width hit target | Pointer | Asserts the clickable region (hit target); needs hit testing. | +| widgets/toggle.md:459 | M | toggle preserves checkbox geometry for focus and press feedback | Focus | focus feedback | +| widgets/toggle.md:460 | M | disabled toggle is absent from focus and bubbles activation input | Pointer | caps focus+pointer; latest-landing -> Pointer. Mouse table disabled-click bubbling. | +| widgets/toggle.md:461 | M | enter toggles the bound value exactly once across phased and legacy input | Focus | Enter key routing | +| widgets/toggle.md:462 | M | space toggles the bound value exactly once across phased and legacy input | Focus | Space key routing | +| widgets/toggle.md:463 | M | pointer activation focuses before one same-node value inversion | Pointer | caps focus+pointer; latest-landing -> Pointer. Pointer click-to-focus and same-node activation. | +| widgets/toggle.md:464 | M | toggle clears a pending press when a pointer release leaves its node | Pointer | Pointer drag/release semantics. | +| widgets/toggle.md:469 | M | standalone focused toggle marks its label with an underline and keeps its marker colour | Focus | focused appearance | +| widgets/toggle.md:470 | M | focused toggle in a form row shows the row cursor and a bold focus label without an underline | Focus | focused appearance | +| widgets/toggle.md:478 | M | text style keeps the checkbox keyboard, pointer, focus, and disabled contract | Pointer | caps focus+pointer; latest-landing -> Pointer. Explicitly includes pointer contract. | +| widgets/toggle.md:479 | M | a click anywhere on a text-style Form row acts as Enter and inverts the value once | Pointer | BORDERLINE (focus) resolved: click is the trigger, so Pointer (latest anyway). | + +### Considered and left in the plain `## Requirements` list + +Unmarked rows whose trace names a capability fixture but whose assertion does not need it. If Main disagrees with a line, move the row per its stated reason. + +- `widgets/progress-view.md:477, 482, 483, 484, 485, 486, 488`: Frame-set resolution and label column beside a fixed-width spinner are functions of the charset and one clock instant. Assumes the environment clock value (key L1) can be injected by a test before Clock lands (D9). If not, move all seven to Clock. +- `widgets/scroll-view.md:503-509, 511, 513-515, 519, 527-529`: ScrollView offset is a `Binding`; the test sets it, so geometry, clamp, partial-item and reservation rows are static. +- `widgets/outline.md:471, 472, 474, 475, 486`: Use the Scrolled / minimum fixture but assert indicator reservation, highlight width, colours, thumb LENGTH: independent of the non-zero offset (473 moved because it asserts the thumb ROW). +- `widgets/list.md:328, 334, 346, 348, 358; widgets/table.md:761, 780, 783, 795, 796, 802, 813; widgets/section.md:194`: Selection and sort come from bindings (vanished ids, hidden selections, bars and fills), asserted at offset 0 or independent of focus; no input needed. +- `widgets/form.md:1241, 1242, 1244-1247, 1256, 1257, 1259-1265, 1281`: Closed-field values, validation, scrollbar reservation: bindings and layout only. +- `widgets/picker.md:1295, 1324, 1329, 1347, 1379, 1387, 1388; widgets/multi-picker.md:568, 576, 595; widgets/tab-view.md:439, 451; widgets/stepper.md:377, 383; widgets/text-field.md:453; widgets/button.md:567, 568, 590, 594, 614; widgets/split-view.md:629, 643, 647, 654, 658`: Binding-supplied state, closed host rows, static degradation or style resolution; nothing needs focus, pointer, scroll offset or time. + +## 2. Gaps + +Every `own`/`affects` behaviour and every stated negative behaviour in `participation.jsonl` is either in the "Gaps" table (no existing requirement covers it, DRAFT given) or in "Dropped as covered" (covering requirement named). Draft form is the catalog's: `` `sentence style test name` (trace) ``; traces name sections that exist in the doc (checked against the heading list). `Sub` is the subsection the draft belongs in. + +### 2a. Doc-level mismatches from the brief + +| doc / capability | resolution | +|---|---| +| border / pointer | gap drafted (hit testing follows wrapped child) | +| divider / focus | junction behaviour covered by border.md:256 + split-view.md:661; "no focus, no state, no events" drafted | +| frame / pointer | gap drafted | +| padding / pointer | gap drafted | +| text-selection / focus+pointer+scroll | gaps listed, no drafts (sketch status), 2b | +| view-that-fits / scroll | gap drafted (candidate ScrollView clamp across a switch); node-state row 312 placed Focus | +| z-stack / pointer | gap drafted | +| content-unavailable-view / focus+pointer | two gaps drafted | +| also found: navigation-split-view | absent from participation.jsonl; its four unmarked rows added to Placement (D5) | + +### 2b. `primitives/text-selection.md` (sketch; deferred to the pointer slice): gaps listed, NOT drafted + +The doc has no `## Requirements` section; every behaviour below is uncovered. + +| capability | behaviour (from participation) | +|---|---| +| focus | keyboard gestures (shift-extended selection, select-all) map to semantic ranges; selection ownership is focus-scoped (open question) | +| focus | copy action is an explicit command routed to a responder; clipboard denial leaves range and highlight valid | +| pointer | pointer gestures (click, double-click word, triple-click line, drag) map through frames, clips and wrapping to semantic text ranges | +| pointer | adjacent SplitView/Grid/Table columns stay independent selection regions even on the same terminal rows | +| pointer | drag capture after the pointer leaves the originating fragment | +| pointer | selection highlight uses selection.fill / selection.inactive and is geometrically correct without colour | +| pointer | Text does not retain state or consume pointer events directly | +| pointer | coexistence with terminal-native selection while mouse tracking is effective | +| scroll | selection maps through scrolling; ScrollView offsets and clipped-away fragments participate in range extension and autoscroll | + +### 2c. Gaps with DRAFT requirements + +| # | doc | capability | behaviour with no covering requirement | DRAFT | Sub | +|---|---|---|---|---|---| +| G1 | primitives/background | pointer | hit region follows the clip to the primary final bounds | `background hit testing is clipped to the primary child's final bounds` (anatomy: opaque background; primary final bounds and clipped background layer) | Pointer | +| G2 | primitives/badge | pointer | Badge takes no pointer input | `badge takes no pointer input` (primitive: no mouse table) | Pointer | +| G3 | primitives/border | focus | Border holds no focus state; the owning view chooses set and lineStyle | `border holds no focus state and the owning view chooses its set and line style` (focus border: summary; Public API) | Focus | +| G4 | primitives/border | pointer | ring is decorative; hit testing follows the wrapped child's final frame and clip | `border ring is decorative and hit testing follows the wrapped child's final frame and clip` (Decisions: hit testing deferred to Slice 5) | Pointer | +| G5 | primitives/box | focus | joins focus only through explicit .focusable(id) | `box joins focus only through an explicit focusable id` (focus: self focus; state model) | Focus | +| G6 | primitives/box | focus | focus paints only the foreground of border glyphs and title; backgrounds stay transparent; BorderStyle and title position unchanged | `focus paints only the foreground of the ring and title and leaves backgrounds and title position unchanged` (focus: self focus; Decisions) | Focus | +| G7 | primitives/box | focus | Box owns no key routing; a focusable Box gains no press behaviour | `focusable box owns no key routing and gains no press behaviour` (State model; Decisions) | Focus | +| G8 | primitives/box | pointer | Box names no hit region | `box names no hit region` (Decisions: hit testing deferred to Slice 5) | Pointer | +| G9 | primitives/divider | focus+pointer | divider has no focus, no state, no events; handles no pointer events | `divider takes no focus and handles no key or pointer events` (Overview; Blended junctions) | Pointer | +| G10 | primitives/flex | focus | Flex does not focus or handle input | `flex takes no focus and handles no key or pointer input` (Overview) | Pointer | +| G11 | primitives/flex | scroll | Flex does not scroll | `flex never scrolls` (Overview) | Scroll | +| G12 | primitives/flow | focus | Flow takes no focus and handles no input | `flow takes no focus and handles no key or pointer input` (Identity and focus) | Pointer | +| G13 | primitives/flow | focus | focus order is depth-first source order; Flow places items in source order so focus order and reading order agree | `flow focus order follows source order and agrees with reading order` (Identity and focus) | Focus | +| G14 | primitives/flow | scroll | (evidence only) Flow does not scroll; lines that must stay reachable go in a ScrollView | `flow never scrolls` (Line breaking; Overview) | Scroll | +| G15 | primitives/frame | pointer | frame's final allocated frame clips hit testing of non-text children | `frame clips hit testing of a non-text child to its final allocated frame` (Overview; Sizing: bounded frame) | Pointer | +| G16 | primitives/grid | focus | Grid handles no input | `grid takes no focus and handles no key or pointer input` (Overview) | Pointer | +| G17 | primitives/grid | scroll | Grid never scrolls itself | `grid never scrolls itself` (Overview) | Scroll | +| G18 | primitives/label | focus+pointer | Label has no focus or keys; no pointer behaviour | `label takes no focus, keys or pointer input` (Overview) | Pointer | +| G19 | primitives/label | clock | Label reads no clock | `label reads no clock` (Decisions) | Clock | +| G20 | primitives/leading-rail | focus+pointer | no focus, no key handling; no pointer handling | `leading rail takes no focus and handles no key or pointer input` (Overview) | Pointer | +| G21 | primitives/overlay | pointer | a point inside the primary bounds reaches the overlay layer before the primary (topmost first) | `overlay layer is hit before the primary inside the primary bounds` (Open questions: hit testing respects ZStack order) | Pointer | +| G22 | primitives/overlay | pointer | .allowsHitTesting(false) subtrees are skipped by hit testing | `overlay hit testing skips a layer marked allows hit testing false and reaches the primary` (Open questions: decorative overlay decision) | Pointer | +| G23 | primitives/overlay | pointer | bubbling and click-to-focus follow Slice 5 routing; Overlay installs no input handler | `overlay installs no input handler and supplies only paint order to hit-test routing` (Open questions: hit testing respects ZStack order) | Pointer | +| G24 | primitives/padding | pointer | hit-testing clip region is Padding's final frame, not the child's intrinsic bounds; padding owns no input | `padding hit testing clips to its own final frame and owns no input` (Sizing; placement: final frame) | Pointer | +| G25 | primitives/scroll-indicator | clock | (Overview) no animation | `scroll indicator draws no animation` (Overview) | Clock | +| G26 | primitives/stacks | focus+pointer | stacks take no press or focus behaviour; name no hit regions | `stacks take no press or focus behaviour and name no hit region` (Decisions and deferred scope) | Pointer | +| G27 | primitives/stacks | scroll | stacks never scroll | `stacks never scroll` (Decisions and deferred scope) | Scroll | +| G28 | primitives/style-modifiers | focus+pointer | style modifiers add no focus; the style wrapper has no hit-testing behaviour | `style modifiers add no focus and no hit-testing behaviour` (Overview; Environment) | Pointer | +| G29 | primitives/text | scroll | Text never creates scroll state | `text never creates scroll state` (Degradation) | Scroll | +| G30 | primitives/view-that-fits | focus+pointer | ViewThatFits itself takes no focus and handles no input | `view that fits takes no focus and handles no input of its own` (Identity, state, and focus) | Pointer | +| G31 | primitives/view-that-fits | focus | focused node leaving the order clears focus to nil, recorded in statistics, no neighbour guessed | `a switch that drops the focused id clears focus without guessing a neighbour and records it in statistics` (Identity, state, and focus: focus table, no focusable with id row) | Focus | +| G32 | primitives/view-that-fits | focus | focusable outside the ViewThatFits is untouched by a switch | `a focusable around the whole choice keeps focus across a switch` (Identity, state, and focus: focus table) | Focus | +| G33 | primitives/view-that-fits | focus | same FocusID in two candidates is listed once (chosen only) | `a focus id held by two candidates is listed once for the chosen one` (Identity, state, and focus: focus table; state model: focus membership) | Focus | +| G34 | primitives/view-that-fits | scroll | a candidate ScrollView's offset survives a switch and is clamped on its next layout as chosen | `a chosen-again scroll view candidate clamps its kept offset on its next layout` (state model: candidate state) | Scroll | +| G35 | primitives/z-stack | pointer | hit testing visits children topmost first (later over earlier) | `zstack hit testing visits children topmost first with later children over earlier` (Allocation; Decisions: Slice 5 hit testing) | Pointer | +| G36 | widgets/button | focus | printable characters focused: no state change, bubbles | `focused button leaves printable characters unconsumed and unchanged` (key table: printable characters) | Focus | +| G37 | widgets/button | focus | Esc while isPressed cancels the press without invoking; not consumed | `esc while a press is pending cancels it without invoking and is not consumed` (key table: Esc) | Focus | +| G38 | widgets/button | focus | isPressed cleared on node removal and every update where the node is not focused | `button clears isPressed on node removal and on every update where it is not focused` (state model: is pressed) | Focus | +| G39 | widgets/button | pointer | click-to-focus first, then primary down sets isPressed | `pointer press focuses the button before it sets isPressed` (mouse table: click) | Pointer | +| G40 | widgets/button | pointer | double-click: no distinct gesture; each valid same-node click invokes once | `double-click is no distinct gesture and each valid same-node click invokes once` (mouse table: double-click) | Pointer | +| G41 | widgets/button | pointer | drag while pressed: release outside the node clears isPressed and never invokes; not consumed | `button clears a pending press when a pointer release leaves its node` (mouse table: drag) | Pointer | +| G42 | widgets/button | pointer | hover cleared on motion-tracking disable and node removal; disabled button renders as not hovered | `hover clears when motion tracking stops or the node is removed, and a disabled button renders as not hovered` (state model: is hovered; mouse table: move) | Pointer | +| G43 | widgets/callout | focus+pointer | Callout is not focusable: no key table, no responder, no keys; no mouse table, no pointer behaviour | `callout takes no focus, keys or pointer input` (Key table; Mouse table) | Pointer | +| G44 | widgets/code-view | focus+pointer | CodeView takes no focus and consumes no keys; takes no pointer input in 1.0 | `code view takes no focus, keys or pointer input in 1.0` (Key table; Mouse table) | Pointer | +| G45 | widgets/content-unavailable-view | focus+pointer | the view owns no keys, no responder and no pointer regions | `content unavailable view owns no keys, responder or pointer regions` (Key table; Mouse table; State model (delegation)) | Pointer | +| G46 | widgets/content-unavailable-view | focus+pointer | action Buttons are ordinary focusable Buttons with their own key and mouse tables | `content unavailable view action buttons keep their own focus, key and click behaviour` (Key table; Mouse table; State model (delegation)) | Pointer | +| G47 | widgets/form | focus | Form is one focus stop; Tab moves past it | `form is one focus stop and tab moves past it` (State model; Key table; Overview) | Focus | +| G48 | widgets/form | focus | cursor clamped to field count | `form cursor clamps to the field count when fields disappear` (state model: cursor) | Focus | +| G49 | widgets/form | focus | PgUp/PgDn move the cursor one window, clamped | `page up and page down move the form cursor one window and clamp` (key table: PgUp, PgDn) | Focus | +| G50 | widgets/form | focus | Left/Right on enum row cycles; on number row steps clamped to min/max | `left and right cycle an enum row and step a number row clamped to its range` (key table: Left, Right) | Focus | +| G51 | widgets/form | focus | Left/Right on other row kinds: no effect, not consumed | `left and right on other row kinds are not consumed` (key table: Left, Right) | Focus | +| G52 | widgets/form | focus | Enter/Space on bool row toggles; on enum row cycles forward | `enter or space toggles a bool row and cycles an enum row forward` (key table: Enter, Space) | Focus | +| G53 | widgets/form | focus | Enter/Space on text row starts editing; on number row no effect | `enter or space starts editing on a text row and does nothing on a number row` (key table: Enter, Space) | Focus | +| G54 | widgets/form | focus | editing keys: Backspace, Ctrl-U, Ctrl-W, Space, printable; all other keys no effect | `editing keys insert, delete and clear text on a text row and all other keys do nothing` (key table: editing text) | Focus | +| G55 | widgets/form | focus | Enter/Esc stops editing | `enter or escape stops editing a text row` (key table: editing text) | Focus | +| G56 | widgets/form | focus | paste appends with newlines/tabs replaced by spaces while editing; ignored when not editing | `paste appends to a text row being edited with line breaks and tabs as spaces and is ignored otherwise` (key table: paste) | Focus | +| G57 | widgets/form | pointer | click on row: click-to-focus first, activates the row as Enter does | `clicking a form row focuses the form first and activates the row as enter does` (mouse table: row) | Pointer | +| G58 | widgets/form | pointer | editing/open flags cleared on outside click | `a click outside the open row clears the editing and open flags` (state model: editing and open flags) | Pointer | +| G59 | widgets/form | pointer | hover cleared on focus loss and when motion tracking stops | `form hovered row clears on focus loss and when motion tracking stops` (state model: hovered row; mouse table: move) | Pointer | +| G60 | widgets/form | scroll | scroll offset NodeState clamped to content rows minus window rows on every render | `form scroll offset clamps to the content rows minus the window rows on every render` (state model: scroll offset) | Scroll | +| G61 | widgets/form | scroll | a field taller than the window top-aligns | `form field taller than the window top-aligns when chased` (state model: scroll offset) | Scroll | +| G62 | widgets/form | scroll | wheel at boundary not consumed | `wheel at a form boundary is not consumed` (mouse table: wheel-up, wheel-down) | Scroll | +| G63 | widgets/key-legend | focus | no focused responder declares none -> empty legend; no magic overlay | `legend with no focused responder bindings is empty and adds no overlay` (state model: bindings; API) | Focus | +| G64 | widgets/key-legend | focus | bindings recomputed on every focus change and render; never stored; nothing in NodeState | `legend bindings are recomputed on every focus change and stored nowhere` (state model: bindings) | Focus | +| G65 | widgets/key-legend | focus | declaring a binding installs no behaviour; graph still routes keys without interpreting them | `declaring a key binding installs no behaviour and keys route as before` (state model: bindings) | Focus | +| G66 | widgets/key-legend | pointer | KeyLegend takes no pointer input | `legend takes no pointer input` (Mouse table) | Pointer | +| G67 | widgets/list | focus | List is one focus stop: rows not individually focusable, Tab moves past the whole widget | `list is one focus stop and tab moves past the whole widget` (Anatomy; State model; Key table) | Focus | +| G68 | widgets/list | focus | hosted in a focus-appearance host the host paints its focus rule; selection-less List follows ScrollView's delegated focus appearance | `list leaves its focus treatment to a hosting box and without a selection binding follows the scroll view's delegated focus appearance` (state model: selection style; scroll-view focus appearance) | Focus | +| G69 | widgets/list | focus | Up/Down saturate at first/last selectable row and stay consumed | `up and down at the first and last selectable row saturate and stay consumed` (key table: Up, Down) | Focus | +| G70 | widgets/list | focus | Home/End select first/last selectable row | `home and end select the first and last selectable row` (key table: Home, End) | Focus | +| G71 | widgets/list | focus | PgUp moves selection one viewport of rows up, clamped (list.md:356 covers PgDn only) | `page up moves selection to the item one viewport of rows above` (key table: PgUp) | Focus | +| G72 | widgets/list | pointer | click on data row: click-to-focus runs first; consumed | `click on a data row focuses the list before it selects and is consumed` (mouse table: click) | Pointer | +| G73 | widgets/list | pointer | right and middle clicks have no List use | `right and middle clicks have no list use` (mouse table) | Pointer | +| G74 | widgets/list | pointer | click or drag on scroll indicator is not routed (output-only) | `click or drag on the list scroll indicator is not routed` (mouse table: scroll indicator) | Pointer | +| G75 | widgets/list | scroll | without a selection binding boundary arrows leave offset unchanged and are not consumed | `without a selection binding boundary arrows leave the offset unchanged and are not consumed` (key table: Down, binding absent) | Scroll | +| G76 | widgets/list | scroll | selection may scroll out of view under wheel scrolling; bar/fill not rendered until it returns | `selection scrolled out of view by the wheel draws no bar or fill until it returns` (state model: scroll offset) | Scroll | +| G77 | widgets/multi-picker | focus | menu: Up/Down/Home/End move cursor as Picker's menu (edge clamp, no wrap), selection unchanged | `multipicker menu up, down, home and end move the cursor with edge clamping and leave the selection unchanged` (key table: menu) | Focus | +| G78 | widgets/multi-picker | focus | menu: PgUp/PgDn move cursor by viewport rows | `multipicker menu page up and page down move the cursor by viewport rows` (key table: menu PgUp and PgDn) | Focus | +| G79 | widgets/multi-picker | focus | Enter editing with empty draft ends editing; Esc editing clears draft | `multipicker empty draft Enter ends editing and escape clears the draft` (key table: menu Enter, editing; menu Esc) | Focus | +| G80 | widgets/multi-picker | focus | Esc when not editing bubbles | `multipicker menu escape outside editing bubbles` (key table: menu Esc) | Focus | +| G81 | widgets/multi-picker | focus | Backspace/Ctrl-U/Ctrl-W/printable edit draft; other keys swallowed while editing | `multipicker draft editing keys edit the draft and swallow all other keys` (key table: editing rows) | Focus | +| G82 | widgets/multi-picker | focus | digit on custom row starts editing and moves cursor to it | `multipicker menu digit on the Other row moves the cursor and starts editing` (key table: menu digits) | Focus | +| G83 | widgets/multi-picker | focus | Left/Right/other keys in menu bubble; unfocused, disabled or empty group returns Skip | `multipicker left, right and unlisted keys bubble and an unfocused, disabled or empty group leaves keys unconsumed` (key table: menu; radio) | Focus | +| G84 | widgets/multi-picker | focus | radio group with zero options installs no handler | `multipicker radio group with no options installs no handler` (key table: radio) | Focus | +| G85 | widgets/multi-picker | focus | host row closed: other keys bubble | `multipicker closed host row bubbles keys other than enter and space` (host keys: closed) | Focus | +| G86 | widgets/multi-picker | focus | host row open: Up/Down clamp; Left/Right/other keys consumed; Tab/Shift-Tab bubble | `multipicker open host row clamps up and down, consumes left, right and unlisted keys, and bubbles tab and shift-tab` (host keys: open list) | Focus | +| G87 | widgets/multi-picker | focus | disabled host row is absent from focus and cannot open | `disabled multipicker host row is absent from focus and cannot open` (state model: open; host keys) | Focus | +| G88 | widgets/multi-picker | focus | ? cursor and checked-chip underline drawn only while focused | `multipicker draws its cursor and underlines checked chips only while focused` (state model: is focused) | Focus | +| G89 | widgets/multi-picker | pointer | every click that acts installs the MultiPicker's focus first | `a multipicker click that acts installs the multipicker's focus first` (mouse table; host mouse) | Pointer | +| G90 | widgets/multi-picker | pointer | menu click while editing ends editing, discards the draft, then toggles | `multipicker menu click while editing ends editing and discards the draft before it toggles` (mouse table: menu option row) | Pointer | +| G91 | widgets/multi-picker | pointer | click on title row or below last option bubbles | `multipicker menu click on the title row or below the last option bubbles` (mouse table: menu) | Pointer | +| G92 | widgets/multi-picker | pointer | zero-option menu wheel bubbles | `multipicker menu wheel with zero options bubbles` (mouse table: menu wheel) | Pointer | +| G93 | widgets/multi-picker | pointer | radio group gutter/gap clicks, wheel and other mouse bubble | `multipicker radio group clicks outside chips, wheel and other mouse input bubble` (mouse table: radio) | Pointer | +| G94 | widgets/multi-picker | pointer | click when disabled leaves everything unchanged, not consumed | `disabled multipicker click leaves everything unchanged and unconsumed` (mouse table: disabled) | Pointer | +| G95 | widgets/multi-picker | pointer | host row: click on closed row opens; click on open row closes | `multipicker host row click opens a closed list and closes an open one` (host mouse) | Pointer | +| G96 | widgets/multi-picker | pointer | host row: click on option row moves cursor, toggles, list stays open | `multipicker host row click on an option row moves the cursor and toggles with the list staying open` (host mouse) | Pointer | +| G97 | widgets/multi-picker | pointer | host row: click outside closes without committing and bubbles | `multipicker host row click outside closes the list without committing and bubbles` (host mouse) | Pointer | +| G98 | widgets/multi-picker | pointer | host row: wheel in open list moves cursor clamped, consumed | `multipicker host row wheel in an open list clamps at the edges and is consumed` (host mouse) | Pointer | +| G99 | widgets/multi-picker | scroll | menu scroll offset clamped to 0...max(0, content rows - visible rows) | `multipicker menu scroll offset clamps to zero through the last content row` (state model: scroll offset) | Scroll | +| G100 | widgets/multi-picker | scroll | reveal re-applies to the cursor row | `multipicker menu reveal re-applies to the cursor row by the fewest rows` (state model: scroll offset) | Scroll | +| G101 | widgets/multi-picker | scroll | options taller than the viewport align first row with top | `multipicker option taller than the viewport aligns its first row with the top` (state model: scroll offset) | Scroll | +| G102 | widgets/multi-picker | scroll | title row outside the scrolling region | `multipicker title row stays outside the scrolling region` (state model: scroll offset; Menu) | Scroll | +| G103 | widgets/outline | focus | Outline is focusable via explicit .focusable | `outline joins focus only through an explicit focusable id` (State model; Key table) | Focus | +| G104 | widgets/outline | focus | is-focused drives the selection bar: accent when focused, secondary otherwise | `outline selection bar is accent while focused and secondary otherwise` (State model: is focused; Anatomy) | Focus | +| G105 | widgets/outline | focus | Right on a leaf and Left on a root row bubble | `right on a leaf and left on a root row bubble` (Key table: Right, Left) | Focus | +| G106 | widgets/outline | scroll | scroll offset clamps to content bounds on every update | `outline scroll offset clamps to the content bounds on every update` (State model: scroll offset) | Scroll | +| G107 | widgets/outline | scroll | selection-moving keys reveal the new row | `selection-moving keys reveal the new row by the fewest rows` (State model: scroll offset; Key table) | Scroll | +| G108 | widgets/picker | focus | no option / disabled / unfocused -> arrow keys bubble | `picker with no options or without focus leaves arrow keys unconsumed` (key table: inline) | Focus | +| G109 | widgets/picker | focus | radio/segmented from absent value writes first option | `picker radio group and segmented control write the first option from an absent value` (key table: radio; segmented) | Focus | +| G110 | widgets/picker | focus | single option consumes Left/Right and leaves selection unchanged | `picker radio group and segmented control with one option consume left and right and leave the selection unchanged` (key table: radio; segmented) | Focus | +| G111 | widgets/picker | focus | menu Home/End consumed even when already there | `picker menu home and end are consumed even when the cursor is already there` (key table: menu Home and End) | Focus | +| G112 | widgets/picker | focus | menu Enter/Space commit the cursor option (picker.md:1323 covers Enter only) | `picker menu space commits the cursor option through the binding` (key table: menu Enter and Space) | Focus | +| G113 | widgets/picker | focus | menu editing: empty draft Enter ends editing | `picker menu empty draft Enter ends editing` (key table: editing rows) | Focus | +| G114 | widgets/picker | focus | Esc with custom row chosen (not editing) clears the draft; Esc otherwise bubbles | `picker menu escape outside editing bubbles unless the custom row is chosen` (key table: menu Esc) | Focus | +| G115 | widgets/picker | focus | host row open: PgUp/PgDn/Home/End move the cursor like the menu | `picker open host list page and home and end keys move the cursor like the standalone menu` (key table: host open list) | Focus | +| G116 | widgets/picker | focus | host row open: Up/Down clamp; Left/Right/other keys consumed; Tab/Shift-Tab bubble | `picker open host row clamps up and down, consumes left, right and unlisted keys, and bubbles tab and shift-tab` (key table: host open list) | Focus | +| G117 | widgets/picker | focus | disabled host row absent from focus and cannot open | `disabled picker host row is absent from focus and cannot open` (Menu collapsed in a host row; state model) | Focus | +| G118 | widgets/picker | focus | inline row focus uses the focus role | `picker inline row draws its focus in the focus role` (state model: is focused; focus presentation) | Focus | +| G119 | widgets/picker | focus | cursor index starts on chosen option or first option and is not written to selection | `picker cursor starts on the chosen or first option and never writes the selection` (state model: cursor index) | Focus | +| G120 | widgets/picker | focus | KeyLegend shows `1-9 choose` for menu | `key legend shows 1-9 choose for a picker menu` (key table: menu digits; KeyLegend) | Focus | +| G121 | widgets/picker | pointer | any click that selects installs Picker's focus first in every presentation | `a picker click that selects installs the picker's focus first in every presentation` (mouse table; host mouse) | Pointer | +| G122 | widgets/picker | pointer | chevrons not independently clickable (no focus, no change) | `picker inline chevrons are not independently clickable` (mouse table: inline row) | Pointer | +| G123 | widgets/picker | pointer | radio/segmented: gutter/gaps and i beyond count bubble; radio wheel bubbles | `picker radio group and segmented control clicks outside chips and radio wheel input bubble` (mouse table: radio; segmented) | Pointer | +| G124 | widgets/picker | pointer | menu: click custom row starts/continues editing | `picker menu click on the custom row starts or continues editing` (mouse table: menu custom row) | Pointer | +| G125 | widgets/picker | pointer | menu: click on option row while editing ends editing and discards the draft first | `picker menu click while editing ends editing and discards the draft before it commits` (mouse table: menu option row) | Pointer | +| G126 | widgets/picker | pointer | menu: click title row or below last option bubbles | `picker menu click on the title row or below the last option bubbles` (mouse table: menu) | Pointer | +| G127 | widgets/picker | pointer | zero-option menu wheel bubbles | `picker menu wheel with zero options bubbles` (mouse table: menu wheel) | Pointer | +| G128 | widgets/picker | pointer | host row: click closed opens as Enter; click open closes without committing | `picker host row click opens a closed list and closes an open one without committing` (host mouse) | Pointer | +| G129 | widgets/picker | pointer | host row: click option row moves cursor, commits, closes | `picker host row click on an option row moves the cursor, commits and closes` (host mouse) | Pointer | +| G130 | widgets/picker | pointer | host row: click outside closes without committing and bubbles | `picker host row click outside closes the list without committing and bubbles` (host mouse) | Pointer | +| G131 | widgets/picker | pointer | host row: wheel in open list clamps, consumed; wheel on closed host row bubbles | `picker host row wheel clamps in an open list and bubbles on a closed row` (host mouse) | Pointer | +| G132 | widgets/picker | scroll | menu scroll offset clamped on every reconciliation and after width re-wrap | `picker menu scroll offset clamps to zero through the last content row and after a width re-wrap` (state model: menu scroll offset) | Scroll | +| G133 | widgets/picker | scroll | option taller than viewport aligns first row with top | `picker option taller than the viewport aligns its first row with the top` (state model: menu scroll offset) | Scroll | +| G134 | widgets/picker | scroll | title row outside the scrolling region | `picker title row stays outside the scrolling region` (Menu scrolling in rows) | Scroll | +| G135 | widgets/picker | scroll | open list in a host row scrolls in rows like the standalone menu | `picker open host list scrolls in rows like the standalone menu` (Host row; Menu scrolling in rows) | Scroll | +| G136 | widgets/progress-view | focus+pointer | not focusable, consumes no key input, no responder; no pointer regions | `progress view takes no focus, keys or pointer input` (Key table; Mouse table) | Pointer | +| G137 | widgets/progress-view | clock | ASCII-only output draws line at 120 ms for every set | `ascii-only indeterminate view declares the line set's 120 ms tick requirement` (frame sets; degradation) | Clock | +| G138 | widgets/progress-view | clock | custom set = frames + interval | `custom spinner set declares its own interval as the tick requirement` (frame sets; state model: tick requirement) | Clock | +| G139 | widgets/progress-view | clock | tick requirement recomputed on every update; aggregated by the graph over live nodes | `tick requirement is recomputed on every update and aggregated by the graph over live nodes` (state model: tick requirement) | Clock | +| G140 | widgets/progress-view | clock | no reduced-motion setting in 1.0 | `progress view has no reduced-motion setting in 1.0` (state model; Open questions) | Clock | +| G141 | widgets/scroll-view | focus | explicit external identity registers unconditionally | `an explicit external focus identity registers unconditionally` (state model: owned focus eligibility) | Focus | +| G142 | widgets/scroll-view | focus | non-focus initializer (no binding/no focus) receives no keyboard scroll events | `scroll view without an offset binding or focus identity receives no keyboard scroll events` (Key table) | Focus | +| G143 | widgets/scroll-view | focus | Tab remains unclaimed; unlisted keys bubble | `scroll view leaves tab and unlisted keys unconsumed` (Key table) | Focus | +| G144 | widgets/scroll-view | focus | disabled (isEnabled) gates focus eligibility | `disabled scroll view is not focus eligible` (state model: owned focus eligibility) | Focus | +| G145 | widgets/scroll-view | focus | reveal on focus change: nothing moves without a controlled offset | `focus reveal moves nothing without a controlled offset` (Key table: reveal on focus change) | Scroll | +| G146 | widgets/scroll-view | pointer | click on viewport content: click-to-focus before handler routing; ScrollView returns ignored | `click on viewport content gets click-to-focus and the scroll view returns ignored without reinterpreting it` (Mouse table: click) | Pointer | +| G147 | widgets/scroll-view | pointer | no pointer capture / second gesture recognizer introduced | `scroll view introduces no pointer capture or second gesture recognizer` (Decisions; Mouse table) | Pointer | +| G148 | widgets/scroll-view | pointer | no Shift-wheel or signed deltas | `scroll view ignores shift-wheel and signed wheel deltas` (Mouse table) | Scroll | +| G149 | widgets/scroll-view | pointer | hit testing deepest-first: an inner ScrollView gets a wheel first | `an inner scroll view receives the wheel before an enclosing one` (Mouse table; Decisions) | Scroll | +| G150 | widgets/scroll-view | scroll | controlled out-of-range binding never rewritten passively; next user movement writes from the effective value | `the next user movement writes from the effective value of an out-of-range controlled offset` (state model: controlled offset) | Scroll | +| G151 | widgets/split-view | focus | each live divider focusable with a manager-only FocusID derived from adjacent pair + axis | `each live divider is focusable with a focus id derived from its adjacent pane pair and axis` (Interaction: focus; state model) | Focus | +| G152 | widgets/split-view | focus | divider focusable only while keyboardResizingEnabled and the pair has an adjustable cell | `divider is focusable only while keyboard resizing is enabled and the pair has an adjustable cell` (Interaction: focus; state model) | Focus | +| G153 | widgets/split-view | focus | focused divider handle cleared if pair disappears, becomes non-resizable or axis changes | `focused divider handle clears when its pair disappears, becomes non-resizable or the axis changes` (state model) | Focus | +| G154 | widgets/split-view | focus | removed focused node still clears focus | `removing the focused node still clears focus` (Interaction: collapse restoration) | Focus | +| G155 | widgets/split-view | pointer | click on divider handle: click-to-focus; pane config unchanged; consumed | `clicking a divider handle focuses it and leaves pane configuration unchanged` (mouse table: click) | Pointer | +| G156 | widgets/split-view | pointer | drag only along main axis; cross-axis ignored | `divider drag follows the main axis only and ignores cross-axis movement` (mouse table: drag) | Pointer | +| G157 | widgets/split-view | pointer | pointer drag cancelled on axis change, focus loss, node identity loss, pane removal | `pointer drag cancels on axis change, focus loss, node identity loss and pane removal` (state model: pointer drag) | Pointer | +| G158 | widgets/split-view | pointer | no hover, wheel or click-to-collapse behaviour | `split view has no hover, wheel or click-to-collapse behaviour` (Decisions; mouse table) | Pointer | +| G159 | widgets/split-view | pointer | handles remain fully interactive in ASCII; one-cell handle available without mouse capability | `split view handles stay interactive under ASCII and show a one-cell handle without mouse capability` (Degradation; mouse table) | Pointer | +| G160 | widgets/stepper | focus | Up/Right increment, Down/Left decrement (stepper.md:371-372 trace Up/Down only) | `stepper treats right as increment and left as decrement like up and down` (key table: Right, Left) | Focus | +| G161 | widgets/stepper | pointer | pressed affordance set only on valid primary down or phased key down; cleared on release, cancellation, focus loss, disablement, removal, affordance unavailable | `stepper pressed affordance sets only on a valid primary down or phased key down and clears on release, cancellation, focus loss, disablement, removal or an unavailable affordance` (state model: pressed affordance) | Pointer | +| G162 | widgets/stepper | pointer | click on value slot or separators: no handler, unconsumed | `click on the stepper value slot or separators has no handler and is unconsumed` (mouse table) | Pointer | +| G163 | widgets/tab-view | focus | every other key bubbles; unfocused tab view handles no keys | `tab view bubbles every key except left and right and handles no keys while unfocused` (key table) | Focus | +| G164 | widgets/tab-view | focus | ASCII cursor >, active tab flat bold label in focus while focused else accent | `ascii tab view draws the cursor as > and the active tab as a flat bold label in focus while focused and accent otherwise` (states; Degradation: ASCII) | Focus | +| G165 | widgets/tab-view | focus | binding matching no tab: no pill, empty pane, cursor still shows while focused | `focused tab view with no matching tab shows its cursor with no pill and an empty pane` (state model: selection; states) | Focus | +| G166 | widgets/tab-view | pointer | wheel-up/down and drag on frame: no effect, not consumed | `wheel and drag on the tab frame have no effect and are not consumed` (mouse table) | Pointer | +| G167 | widgets/table | focus | selection.inactive when unfocused | `unfocused table renders its selection in the inactive style` (state model: selection style) | Focus | +| G168 | widgets/table | focus | single-select Down/Up move selection via binding; saturate and stay consumed | `down and up move the selection through the binding and saturate consumed at the ends` (key table: Down, Up) | Focus | +| G169 | widgets/table | focus | Home/End first/last | `home and end select the first and last row` (key table: Home, End) | Focus | +| G170 | widgets/table | focus | PgDn/PgUp one viewport | `page up and page down move selection one viewport of rows and clamp` (key table: PgUp, PgDn) | Focus | +| G171 | widgets/table | focus | Down with unset selection selects first visible row | `down with no selection selects the first visible row` (key table: Down, selection not set) | Focus | +| G172 | widgets/table | focus | cursor moves never write selection | `cursor moves never write the selection` (state model: cursor) | Focus | +| G173 | widgets/table | pointer | single-select click on data row sets selection via binding; consumed | `click on a data row in single-select sets the selection through the binding and is consumed` (mouse table: click) | Pointer | +| G174 | widgets/table | scroll | row taller than viewport aligns first row with top | `table row taller than the viewport aligns its first row with the top` (state model: scroll offset) | Scroll | +| G175 | widgets/table | scroll | scroll offset and cursors have one runtime owner (not mirrored into ResponderState) | `table scroll offset and cursors are not mirrored into responder state` (state model: scroll offset) | Scroll | +| G176 | widgets/text-field | focus | focused frame heavy in focus (NO_COLOR heavy bold, ASCII +-\|) | `focused text field draws its frame heavy in the focus role, bold under NO_COLOR and plus-hyphen-bar under ASCII` (Degradation; Anatomy) | Focus | +| G177 | widgets/text-field | focus | focus lost/node removed clears focus and the cursor request | `focus loss or node removal clears the field's cursor request` (state model: caret terminal position) | Focus | +| G178 | widgets/text-field | focus | zero width yields no cursor request | `a zero-width text field requests no hardware cursor` (state model: caret terminal position) | Focus | +| G179 | widgets/text-field | focus | Left/Right one grapheme; Shift-Left/Right extend selection from anchor | `left and right move the caret one grapheme and shift extends the selection from the anchor` (key table: Left, Right, Shift-Left, Shift-Right) | Focus | +| G180 | widgets/text-field | focus | Alt-Left/Right word boundary; Ctrl-Left/Right word boundary only when protocol distinguishes else bubbles | `alt arrows move by word and control arrows do so only when the keyboard protocol distinguishes them, otherwise they bubble` (key table: Alt-Left, Alt-Right, Ctrl-Left, Ctrl-Right) | Focus | +| G181 | widgets/text-field | focus | Home/End; Home resets reveal to leading boundary; End reveals the end | `home and end move the caret to the leading and trailing boundary and set the reveal offset` (key table: Home, End) | Focus | +| G182 | widgets/text-field | focus | unlisted keys bubble | `unlisted keys bubble from a text field` (key table) | Focus | +| G183 | widgets/text-field | focus | no undo/yank, no clipboard in 1.0 | `text field has no undo, yank or clipboard behaviour in 1.0` (Deferred post-1.0; key table) | Focus | +| G184 | widgets/text-field | pointer | mouse requirement declared only while an enabled TextField is live; input line is the one hit region | `text field declares a mouse requirement only while an enabled instance is live and the input line is its one hit region` (Mouse table) | Pointer | +| G185 | widgets/text-field | pointer | double-click and drag on input line: no change, bubble; right and middle clicks no use | `double-click and drag on the input line bubble unchanged and right and middle clicks have no use` (mouse table) | Pointer | +| G186 | widgets/text-field | scroll | horizontal reveal offset clamps to a legal grapheme boundary on every layout/update (placed Focus, see Disagreements D2) | `text field reveal offset clamps to a legal grapheme boundary on every layout` (state model: horizontal reveal offset) | Focus | +| G187 | widgets/text-field | clock | no animated cursor / software caret | `text field draws no animated cursor and no software caret` (Anatomy; render contract) | Clock | +| G188 | widgets/toggle | focus | release after focus loss/disablement/removal only clears pressed, never inverts | `release after focus loss, disablement or removal clears pressed state without inverting` (key table) | Focus | +| G189 | widgets/toggle | focus | printable characters focused: no state change, bubbles | `focused toggle leaves printable characters unconsumed and unchanged` (key table: printable characters) | Focus | +| G190 | widgets/toggle | focus | isPressed cleared on release, cancellation, focus loss, disablement, removal, every unfocused update | `toggle clears isPressed on release, cancellation, focus loss, disablement, removal and every unfocused update` (state model: is pressed) | Focus | +| G191 | widgets/toggle | pointer | double-click: no distinct gesture, each valid same-node click inverts once | `double-click is no distinct gesture and each valid same-node click inverts once` (mouse table: double-click) | Pointer | +| G192 | widgets/toggle | pointer | no move row: Toggle draws no hover and requests no pointer motion | `toggle draws no hover and requests no pointer motion` (mouse table: no move row) | Pointer | +| G193 | widgets/toggle | pointer | hit target is the whole control (not marker-only); below marker floor no marker and no hit target | `toggle hit target is the whole control and disappears below the marker floor` (mouse table; sizing) | Pointer | +| G194 | widgets/image | clock | (evidence only) Loading shows a static placeholder (no spinner/animation) | `image loading placeholder is static and draws no spinner or animation` (State model: load phase) | Clock | + +### 2d. Dropped as covered (behaviour -> covering requirement) + +- **primitives/background**: pointer: layer hit-tested by default / decorative layer must opt out with allowsHitTesting(false) -> background.md:147 (name states explicit opt-out is required, i.e. no implicit decorative default) +- **primitives/badge**: focus: no focus, no keys -> badge.md:325 +- **primitives/border**: focus: self focus heavy ring -> border.md:253; focus within nearest ring -> border.md:254; tint switch off by focusWithinBorder(false)/focusSection -> box.md:348; cursor rows -> border.md:255, form.md:1235; unfocused stepper keeps arrows -> stepper.md:381; SplitView divider and joints -> border.md:256, split-view.md:660-661; bordered table -> border.md:257, table.md:784; heavy/double/ASCII rings -> border.md:258-259; focus role no hue every tier -> border.md:260 +- **primitives/box**: focus: self focus -> box.md:343; focus within -> box.md:344-345; nested -> box.md:346; nothing focusable inside -> box.md:347; switch off -> box.md:348; delegated focus appearance (Box paints after descendants, ScrollView keeps key ownership) -> scroll-view.md:520; ASCII/NO_COLOR/16-colour -> border.md:259-260 +- **primitives/divider**: focus: junction draws in focus role where a focused SplitView line meets it -> border.md:256, split-view.md:661 (the "otherwise border role" half is static and belongs in plain Requirements) +- **primitives/flow**: focus: NodeState survives a reflow / focus preserved -> flow.md:313; radio chips focus cursor gutter (a Picker/MultiPicker behaviour, D11) -> picker.md:1316; multi-picker drafted in Gaps +- **primitives/scroll-indicator**: focus: takes no focus -> scroll-indicator.md:377; pointer: installs no click/wheel/key/drag/hit region -> scroll-indicator.md:377; click or drag falls through to parent which returns .ignored -> scroll-view.md:542, form.md:1291, table.md:759, (list gap drafted) +- **primitives/text**: focus: installs no responder, never focusable, key dispatch continues, never creates focus -> text.md:371; pointer: click/drag/wheel not consumed -> text.md:371 +- **primitives/view-that-fits**: focus: membership recomputed / unchosen candidates out of focus order / never focused so never key-routed -> view-that-fits.md:313; same id keeps focus -> :314; no focusable with id clears -> :315; pointer: unchosen never hit -> :303; terminal requirements aggregate over every live node -> :316 (placed Focus, see Disagreements D4); scroll: offset above the VTF survives -> :312 (generic node state) +- **widgets/button**: focus: joins focus only when explicit and enabled; disabled absent -> button.md:605, :607; focused look -> :579, :583-586; focused+pressed -> :613; NO_COLOR/ASCII -> :592-593; Enter/Space phased and legacy -> :599-603; release after focus loss/disable only clears -> :604; Enter/Space when disabled -> :605; defaultAction -> :611; cancelAction -> :612; disablement clears isPressed and removes from focusability -> :604-605. pointer: same-node click -> :606; click when disabled -> :605; move/hover target, underline, derived never stored -> :589, :608, :610; hover consumer only through built-in style -> :609; hit target is the final frame, forced width -> :598 +- **widgets/content-unavailable-view**: (none of the participation behaviours is covered; both drafted in Gaps) +- **widgets/form**: focus: cursor starts on first row and keeps its row -> form.md:1292; Up/Down not consumed at ends -> :1283; Enter/Space opens Picker menu -> :1284; dropdown keys belong to Picker -> :1285; focus loss closes dropdown -> :1286; editing cleared on focus loss -> :1293; focus presentation and ASCII cursor -> :1235-1237, :1254; failing field -> :1258. pointer: click sets cursor and closes other dropdown -> :1287; click on open dropdown -> :1288; scrollbar ignored -> :1291; wheel -> :1290; hover -> :1239. scroll: chase -> :1280; partly visible field -> :1279 (unmarked add); scrollbar reservation geometry -> :1281 (static) +- **widgets/image**: focus/pointer: takes no focus, keys or pointer input -> image.md:394 +- **widgets/key-legend**: focus: takes no focus and consumes no keys -> key-legend.md:277; entries from focused responder bindings -> :276; legendPriority/drop-by-priority/narrow/floor/ASCII/NO_COLOR rows -> :265, :268, :269, :270, :264-267, :271-275 (all unmarked but moved to Focus, see Placement) +- **widgets/labeled-content**: focus/pointer: no node state, no focus, keys and pointer events bubble -> labeled-content.md:379 +- **widgets/list**: focus: arrow auto-commit -> list.md:322; Down unset -> :323; skips headers -> :324; collapse/hidden selection -> :329-330; Left/Right -> :331; Enter -> :336; unfocused tint -> :342; bar accent/secondary -> :347; absent binding arrows scroll -> :335; PgDn -> :356 (exception, Focus). pointer: double-click -> :337; click row -> :338; header toggle -> :339 (non-collapsible header -> section.md:192); hover fill, motion request, disabled, hovered row, clears -> :359-364; wheel boundary -> :327. scroll: clamp -> :333; reveal -> :350-352; width re-measure -> :353; wheel -> :326; re-expansion no auto-scroll -> :332; partly visible -> :349 (unmarked add) +- **widgets/markdown**: focus/pointer: takes no focus, keys or pointer input -> markdown.md:506; clock: no streamed reveal -> :515 (the link-click-is-OSC-8 note is static, markdown.md:505) +- **widgets/multi-picker**: focus: Space toggles -> multi-picker.md:553; Enter bubbles/never toggles -> :554-555; digits -> :556-557; Other row Space/click -> :573; accepted draft -> :574; rejected draft keeps editing -> :575; no duplicates -> :577; radio group Space/Enter/Left-Right/Up-Down -> :585-587, :591; host closed Enter/Space -> :597; host open Space/Enter/Esc/digit -> :598-600, :606; focus loss closes -> :601; paste -> :561. pointer: menu click -> :559; wheel moves cursor consumed -> :560; Other row click -> :573; radio click -> :588; no move row -> :562. scroll: wheel moves cursor, reveal scrolls -> :560, :566 +- **widgets/navigation-split-view**: (no participation entry; see Disagreements D5) rows :349-352 are existing requirements that cover the key table and mouse table +- **widgets/outline**: focus: tree keys -> outline.md:488, :492-498; pointer: click/expander/double-click -> :499-500; wheel and boundary -> :501-502; scroll: scrolls itself -> :470; indicator/virtualization static -> :471, :485 +- **widgets/picker**: focus: absent cycle -> picker.md:1284; inline advance/retreat -> :1285-1286; endpoints bubble -> :1287; disabled unfocusable -> :1290; radio/segmented wrap -> :1313-1314, :1362; menu clamp -> :1322; menu Enter -> :1323; PgUp/PgDn -> :1335 (exception, Focus); custom row editing, rejected draft, Esc, committed row -> :1341-1346; digits -> :1367-1370; host closed/open keys -> :1380-1385; paste -> :1326; focus presentation -> :1316, :1358-1359. pointer: value-slot click -> :1288-1289; radio/segmented click -> :1315, :1363; menu click -> :1327; wheel -> :1325; no hover -> :1296. scroll: menu scrolls/partial items -> :1333; reveal -> :1334 +- **widgets/progress-view**: clock: frame derived from clock, stores no state -> progress-view.md:489; tick requirement/determinate none/orbit fallback interval -> :490-492; built-in sets and intervals -> :487; label at c2 for every set -> :481; ASCII line / custom fallback glyph sets -> :484-485 (static, plain) +- **widgets/scroll-view**: focus: whileScrollable registers/withdraws -> scroll-view.md:523; arrows/page/home/end -> :516-517; boundary bubbling -> :518; repeats -> :525; focus-within bubbling -> :526; reveal on focus change -> :543, :512; focus appearance -> :520-522; owned identity needs offset binding -> :524. pointer: wheel -> :539-541; indicator/corner clicks ignored -> :542. scroll: effective offset, clamp, never rewrite -> :514-515, :538 (static/Scroll); reveal/partial/paging -> :510-512; no animation -> :531 +- **widgets/section**: focus: header not focusable -> section.md:186; forwards row focus/bindings -> :193; collapse via List Left/Right -> list.md:331; hidden selection -> list.md:329, section.md:194 (static); pointer: header click -> :191; non-collapsible -> :192; grouped rows forwarded -> :193 +- **widgets/split-view**: focus: arrows/first press -> split-view.md:634-636; Shift-arrow -> :650; `=` -> :651; Tab/Esc -> :637; look -> :649, :660-662; collapse restoration -> :632-633. pointer: drag -> :638-639; stale drag -> :640; double-click -> :652; grab zone -> :653; vertical transpose -> :648 +- **widgets/stepper**: focus: increment/decrement/endpoint/stride/clamp -> stepper.md:371-379; focus mark -> :384; disabled -> :387; arrows visible unfocused/limit disabled -> :381-382. pointer: arrow click focuses first -> :385; endpoint click unconsumed -> :386; disabled click -> :387 +- **widgets/tab-view**: focus: wrap Left/Right -> tab-view.md:436-438; cursor/pill focus presentation -> :445-447, :450. pointer: chip click -> :440-441; outside chip -> :442; move input/no hover -> :443-444 +- **widgets/table**: focus: Enter -> table.md:767; multi-select keys -> :791-793; header mode -> :797-801; outer ring -> :784-785; bar cell -> :814. pointer: multi click/gutter/double-click -> :788-790; header click -> :764; hover -> :815-820; indicator output-only -> :759. scroll: stays visible/synchronized -> :756-757; wheel -> :758; clamp -> :762; thumb/paging -> :821; partly visible row -> :822 (unmarked add) +- **widgets/text-field**: focus: printable/paste/IME -> text-field.md:444-446; clamps -> :447-448; replace selection -> :449; no caret inside graphemes -> :450; reveal -> :451; Enter/Tab/Esc -> :456-458; Backspace/Delete -> :459; hardware cursor -> :461; disabled -> :462; character limit never caps -> :468 (static). pointer: click -> :460; disabled click -> :462 +- **widgets/toggle**: focus: Enter/Space exactly once -> toggle.md:461-462; disabled -> :460; focus feedback -> :469-470, :459; shared contract across styles -> :478. pointer: click-to-focus then same-node inversion -> :463; drag release outside -> :464; forced-width hit target -> :454; text-style row click -> :479 + +Docs whose participation entry lists only `none` roles with no behaviours (readable-width; the unlisted capabilities of every doc above) need nothing. + +## 3. Disagreements + +- **D1** widgets/key-legend.md: participation puts `focus: own` and lists layout rows (priority, narrow drops, 20-column floor, ASCII, NO_COLOR, keycaps) as Focus behaviours, but the classifiers left 261-275 and 278 unmarked as static. Evidence/resolution: Participation is right on the testability: `KeyLegend()` takes no arguments and "reads the focused responder chain's bindings from the environment" (key-legend.md API, state model: bindings). A legend with entries cannot exist before declared bindings (slice 04). All 16 unmarked rows added to Focus. Role is arguably `affects` (the view takes no focus) but the placement is the same. Counter-case: if tests inject a bindings environment value directly, these 16 stay plain. +- **D2** widgets/text-field.md: participation `scroll: own` for the horizontal reveal offset (while noting "static fixture is testable today"). Evidence/resolution: The reveal offset is derived from the caret (keys/edits/click); it uses no wheel, bubbling, ScrollIndicator or viewport. It is caret/edit machinery, so Focus. Scroll role should be `none`; its behaviours (clamp, minimal adjust, Home/End) are drafted in Focus (G rows for text-field) and text-field.md:451 is Focus. +- **D3** widgets/picker.md and widgets/multi-picker.md: participation `scroll: own` includes "wheel moves the cursor (no separate offset wheel)". Evidence/resolution: The wheel moves the CURSOR and is always consumed (never bubbles); that is Pointer. Only the menu offset behaviours (clamp, reveal, taller option, title outside region, partial items) are Scroll. picker.md:1325 and multi-picker.md:560 placed Pointer. +- **D4** primitives/view-that-fits.md: participation puts "terminal requirements aggregate over every live node" under `pointer: affects` (mouse tracking as example). Evidence/resolution: view-that-fits.md:272 links it to slice 04 `terminal-requirements-aggregation` (focus and key routing). The aggregation machinery lands with Focus; row :316 added to Focus. The Pointer role of VTF is only "unchosen candidates are never hit" (:303). +- **D5** widgets/navigation-split-view.md has no entry in participation.jsonl although it has 14 requirements. Evidence/resolution: Its rows :349 (focus continuity), :350 (no keys/no pointer handlers), :351 (divider drag), :352 (inactive tint on column focus loss) are capability-bound and were unmarked; added. The doc should get a participation entry with focus `affects`, pointer `affects`. +- **D6** widgets/scroll-view.md (and the List/Table/Outline/Form/Picker "scroll indicator ... (static)" items): participation `scroll: own` lists static behaviours (viewport proposals, indicator visibility/reservation, effective offset with a binding, partial item draws, below-12x4 omission). Evidence/resolution: ScrollView offset is a Binding the test sets, and indicator geometry is "testable today" (scroll-indicator.md evidence). Those rows are plain; only input-driven behaviours (wheel, keys, reveal, bubbling, paging of the offset, unbound origin hold) are Scroll. Unmarked scroll-view rows 503-509, 511, 513-515, 519, 527-529 left plain. +- **D7** widgets/progress-view.md: participation `clock: own` bundles static behaviours (label at c2, glyph sets, ASCII fallback glyph) with time-dependent ones. Evidence/resolution: Only the frame-from-instant, interval, tick-requirement and no-reduced-motion behaviours need Clock. Glyph-set resolution is plain (see D9 assumption). +- **D8** widgets/markdown.md: participation `clock: none` with no behaviours. Evidence/resolution: markdown.md:515 ("does not reveal streamed source in 1.0") is a stated negative about time-driven reveal; the clock role should be `none` WITH that negative behaviour, expected to have a requirement (it does; placed Clock). +- **D9** Assumption flagged, not a participation error: whether a test can supply a clock instant before Clock lands. Evidence/resolution: progress-view.md:413 makes the clock an Environment value "supplied by the app or the Phase 5 runtime". If the value type only exists with Clock, rows progress-view.md:477, 482-486, 488 (plain) and any indeterminate-spinner fixture move to Clock. Rows 481, 487, 489-492 are Clock regardless (they assert across frames or the tick requirement). +- **D10** widgets/section.md: participation `focus: own`. Evidence/resolution: Section consumes no keys; the host List dispatches Left/Right (participation's own first behaviour). Role should be `affects`. No placement change (section.md:186 Focus, :191-192 Pointer, :193 Pointer). +- **D11** primitives/flow.md: participation lists "radio-group chips: focus cursor gutter beside a Flow" as a Flow focus behaviour. Evidence/resolution: That is Picker/MultiPicker behaviour (picker.md:1316; multi-picker gap). Flow's own focus behaviours are the NodeState-survives-reflow row (flow.md:313) and the drafted source-order focus rule. Flow `scroll` evidence ("does not scroll") has no listed behaviour; an evidence-only negative is drafted. +- **D12** primitives/overlay.md: participation lists "bubbling and click-to-focus follow Slice 5 routing" as an Overlay pointer behaviour. Evidence/resolution: That is generic hit-test routing, not Overlay behaviour; the Overlay-specific content is paint order and "installs no input handler". Drafted as one combined requirement. +- **D13** widgets/image.md: participation `clock: none` evidence says the Loading placeholder is static (no spinner/animation) but lists no behaviour. Evidence/resolution: Stated negative; an evidence-only draft is included (image Clock row in 2c). Same for primitives/flow.md scroll. +- **D14** Placement disagreement with the classifiers (not the participation audit): paging rows list.md:356, outline.md:495, picker.md:1335. Evidence/resolution: Classified focus+scroll, giving Scroll by the rule. They assert only the target of a selection/cursor move by V static viewport rows; the offset is neither input nor observable in the assertion, and the offset effect is covered by separate reveal rows. Marked EXCEPTION -> Focus. If Main prefers "paging is always Scroll", flip these 3 and the 5 PgUp/PgDn drafts (list, table, form, multi-picker menu, picker open host list) from Focus to Scroll. + +## 4. Summary + +| subsection | marked rows | unmarked adds | total placed | drafted gap requirements | +|---|---|---|---|---| +| Focus | 192 | 27 | 219 | 87 | +| Pointer | 81 | 2 | 83 | 72 | +| Scroll | 37 | 4 | 41 | 27 | +| Clock | 8 | 0 | 8 | 8 | +| none | 1 | 0 | 1 | 0 | +| **total** | 319 | 33 | 352 | 194 | + +- Marked rows resolved to a single subsection: 319 of 319 (28 borderlines resolved; progress-view.md:477 resolved to `none`). +- EXCEPTION rows: 3 (list.md:356, outline.md:495, picker.md:1335, all Focus instead of Scroll). +- Multi-capability marked rows: 60; latest-landing rule applied to all except the 3 exceptions and the borderline resolutions noted in their rows. +- Unmarked rows added to Placement: 33 (16 key-legend, 4 form open-dropdown, 1 picker and 2 multi-picker open-list/uncheck, 1 table cursor, 4 navigation-split-view (2 Focus, 2 Pointer), 1 view-that-fits, 4 Scroll). +- Drafted gap requirements: 194 (text-selection: 9 behaviours listed, 0 drafted). Two are evidence-only (flow scroll, image clock). From ea513afcd63b681ddd5fa56a61204f5d1c14fa29 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Fri, 23 Oct 2026 17:30:22 -0400 Subject: [PATCH 02/37] feat(terminal): add the package seams the headless harness drives Add the package-level seams Slice 2 of plan 036 lists, adapted from the seams spike patch to today's sources, with no new public API: - InMemoryTerminalDevice: package init(...keepsInputOpen:), deliverResize(to:), and closeInput(). An open device registers its byte and size-change streams when terminalDevice is read, so a resize delivered afterwards is never lost and late readers see no replayed history. - InMemoryTerminalSession: package init(device:) to wrap an open device. - TerminalSession: lastDrawn (buffer, cursor, sequence; set after a successful flush), size, and outputTap (teed before the draw's io.write); withApplicationTerminal(configuration:io:...) forwards probeImageID and probeSleep. - ViewGraph: a package init taking the clock the passes read. The seam tests live in TerminalSessionTests and ViewGraphClockTests. --- CHANGELOG.md | 1 + Sources/TesseraCore/Runtime/ViewGraph.swift | 29 +- Sources/TesseraTerminal/TerminalSession.swift | 57 +++- .../InMemoryTerminalDevice.swift | 82 +++++- .../InMemoryTerminalSession.swift | 5 + .../ViewGraphClockTests.swift | 58 ++++ .../TerminalSessionTests.swift | 256 ++++++++++++++++++ 7 files changed, 468 insertions(+), 20 deletions(-) create mode 100644 Tests/TesseraCoreTests/ViewGraphClockTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 25edfdd..fa835a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added denied-by-default OSC 52 clipboard support with validated value types, exact encoding, explicit user intent, payload limits, guarded passthrough, and a demo panel. - Added deterministic mode lifecycle handling, semantic SGR underline variants, colored underlines, renderer transitions, compatibility behavior, snapshots, and exact-byte coverage. - 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 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/Sources/TesseraCore/Runtime/ViewGraph.swift b/Sources/TesseraCore/Runtime/ViewGraph.swift index 6ef8da2..6233a59 100644 --- a/Sources/TesseraCore/Runtime/ViewGraph.swift +++ b/Sources/TesseraCore/Runtime/ViewGraph.swift @@ -20,6 +20,7 @@ private final class _LayoutPlacementCollector { /// An explicit, synchronous runtime tree reconciled from declarative view values. public final class ViewGraph { private let makeRoot: () -> any View + private let now: () -> ContinuousClock.Instant private let baseEnvironment: EnvironmentValues private lazy var rootNode = buildNode( view: makeRoot(), @@ -60,11 +61,22 @@ public final class ViewGraph { } /// Creates and lowers the initial root value immediately. - public init( + public convenience init( root: @escaping () -> Root, size: TerminalSize, environment: EnvironmentValues = EnvironmentValues() ) { + self.init(root: root, size: size, environment: environment) { .now } + } + + /// Creates a graph that reads pass timings from `now` instead of the system clock. + package init( + root: @escaping () -> Root, + size: TerminalSize, + environment: EnvironmentValues = EnvironmentValues(), + now: @escaping () -> ContinuousClock.Instant + ) { + self.now = now makeRoot = root self.size = Self.sanitized(size) baseEnvironment = environment @@ -78,15 +90,14 @@ extension ViewGraph { public func update() { statistics = GraphStatistics() statistics.record(.updateRequested) - let clock = ContinuousClock() - let start = clock.now + let start = now() rootNode = reconcile( node: rootNode, view: makeRoot(), environment: baseEnvironment, environmentOverrides: [] ) - statistics.updateDuration = start.duration(to: clock.now) + statistics.updateDuration = start.duration(to: now()) } /// Measures and places dirty nodes using the graph's vertical composition rule. @@ -96,15 +107,14 @@ extension ViewGraph { } statistics.beginLayoutPass() - let clock = ContinuousClock() - let start = clock.now + let start = now() layoutMeasurements.removeAll(keepingCapacity: true) let proposal = ProposedSize(width: size.columns, height: size.rows) _ = measure(rootNode, proposal: proposal) let rootFrame = Rect(column: 0, row: 0, columns: size.columns, rows: size.rows) place(rootNode, in: rootFrame, clip: rootFrame) clearLayoutFlags(rootNode) - statistics.layoutDuration = start.duration(to: clock.now) + statistics.layoutDuration = start.duration(to: now()) } /// Renders through the borrowed frame capability, laying out first when required. @@ -116,11 +126,10 @@ extension ViewGraph { } layoutIfNeeded() - let clock = ContinuousClock() - let start = clock.now + let start = now() render(rootNode, into: frame) clearRenderFlags(rootNode) - statistics.renderDuration = start.duration(to: clock.now) + statistics.renderDuration = start.duration(to: now()) } /// Changes the graph viewport and invalidates layout only when the size changed. diff --git a/Sources/TesseraTerminal/TerminalSession.swift b/Sources/TesseraTerminal/TerminalSession.swift index 61a55e4..fe1f41a 100644 --- a/Sources/TesseraTerminal/TerminalSession.swift +++ b/Sources/TesseraTerminal/TerminalSession.swift @@ -5,6 +5,14 @@ import TesseraTerminalIO import TesseraTerminalInput import TesseraTerminalRendering +func randomProbeImageID() -> KittyImageID { + KittyImageID(rawValue: UInt32.random(in: 1...UInt32.max)) +} + +func continuousClockSleep(_ duration: Duration) async throws { + try await ContinuousClock().sleep(for: duration) +} + func shouldCoalesceInputEvents( _ buffered: InputEvent, _ incoming: InputEvent @@ -71,6 +79,21 @@ public enum TerminalSessionError: Error, Equatable, Sendable { case closed } +/// The frame a ``TerminalSession`` last flushed to the terminal. +/// +/// A harness seam: it carries what the terminal shows, so a mirror can read it without +/// replaying output bytes. +package struct DrawnFrame: Equatable, Sendable { + /// The cells the frame painted. + package let buffer: Buffer + + /// The cursor position the frame requested, or `nil` when the cursor was hidden. + package let cursor: TerminalPosition? + + /// The count of frames the session has flushed, starting at 1. + package let sequence: Int +} + /// A scoped live-terminal capability for Tessera applications. /// /// The session is closed when its `withApplicationTerminal` scope ends. After that, draw, @@ -127,6 +150,23 @@ public actor TerminalSession { } } + /// The frame the terminal last received; a test and harness seam. + /// + /// A draw sets it once its flush succeeds, so a failed flush leaves the previous frame. + /// ``invalidateRenderer()`` does not clear it: the terminal still shows that frame. + package private(set) var lastDrawn: DrawnFrame? + + /// Receives the encoded bytes of each draw just before they are written, in write order; + /// a test and harness seam. + package var outputTap: (@Sendable ([UInt8]) -> Void)? + + /// The terminal's current size; a test and harness seam. + package var size: TerminalSize { + get async throws { + try await io.size() + } + } + private let probeCoordinator: ActiveCapabilityProbeCoordinator private let probeImageID: KittyImageID private let probeSleep: ActiveCapabilityProbeCoordinator.Sleep @@ -250,11 +290,8 @@ public actor TerminalSession { keyboardProtocol: KeyboardProtocolMode = .legacyOnly, kittyKeyboardFlags: KittyKeyboardFlags = .tesseraDefault, activeProbeTimeout: Duration = .milliseconds(250), - probeImageID: KittyImageID = KittyImageID( - rawValue: UInt32.random(in: 1...UInt32.max)), - probeSleep: @escaping ActiveCapabilityProbeCoordinator.Sleep = { duration in - try await ContinuousClock().sleep(for: duration) - }, + probeImageID: KittyImageID = randomProbeImageID(), + probeSleep: @escaping ActiveCapabilityProbeCoordinator.Sleep = continuousClockSleep, modeLifecycle: ModeLifecycle? = nil ) { let inputEvents = AsyncEventBuffer(coalescing: shouldCoalesceInputEvents) @@ -365,6 +402,8 @@ public actor TerminalSession { configuration: TerminalApplicationConfiguration, io: PlatformIO, environment: [String: String] = TerminalCapabilityDetector.currentEnvironment(), + probeImageID: KittyImageID = randomProbeImageID(), + probeSleep: @escaping ActiveCapabilityProbeCoordinator.Sleep = continuousClockSleep, _ body: (isolated TerminalSession) async throws -> sending R ) async throws -> sending R { let resolution = configuration.resolve(environment: environment) @@ -398,6 +437,8 @@ public actor TerminalSession { keyboardProtocol: resolution.keyboardProtocol, kittyKeyboardFlags: resolution.kittyKeyboardFlags, activeProbeTimeout: resolution.activeProbeTimeout, + probeImageID: probeImageID, + probeSleep: probeSleep, modeLifecycle: lifecycle ) session = constructedSession @@ -717,10 +758,16 @@ public actor TerminalSession { if synchronizedOutput == .enabled { ControlSequence.exitSynchronizedOutput.encode(into: &bytes) } + outputTap?(bytes) await io.write(bytes) do { try await io.flush() lastDrawnBuffer = buffer + lastDrawn = DrawnFrame( + buffer: buffer, + cursor: cursorStorage.pointee, + sequence: (lastDrawn?.sequence ?? 0) + 1 + ) return result } catch { // A failed flush may have written a prefix of the frame, so the next successful draw diff --git a/Sources/TesseraTerminalTestSupport/InMemoryTerminalDevice.swift b/Sources/TesseraTerminalTestSupport/InMemoryTerminalDevice.swift index 196335f..c665ea7 100644 --- a/Sources/TesseraTerminalTestSupport/InMemoryTerminalDevice.swift +++ b/Sources/TesseraTerminalTestSupport/InMemoryTerminalDevice.swift @@ -21,7 +21,11 @@ public enum InMemoryTerminalDeviceEvent: Equatable, Sendable { /// An in-memory terminal device for deterministic terminal I/O tests. public actor InMemoryTerminalDevice { + private var inputContinuations: [AsyncStream<[UInt8]>.Continuation] = [] + private var isInputClosed = false + private let keepsInputOpen: Bool private var recordedEvents: [InMemoryTerminalDeviceEvent] = [] + private var sizeContinuations: [AsyncStream.Continuation] = [] private var storedBytes: [UInt8] = [] private var storedInputBytes: [UInt8] private var storedCellPixelSize: CellPixelSize? @@ -38,17 +42,25 @@ public actor InMemoryTerminalDevice { } /// A terminal device seam backed by this actor's in-memory state. + /// + /// A device that keeps its input open registers one byte stream and one size-change + /// stream with each read of this property, before the seam is returned, so a resize + /// delivered afterwards reaches the stream even if the reader subscribes later. Each + /// call of the seam's `bytes` or `sizeChanges` then returns that same single-reader + /// stream. package var terminalDevice: TerminalDevice { let inputBytes = storedInputBytes + let openStreams = keepsInputOpen ? makeOpenStreams(inputBytes: inputBytes) : nil return TerminalDevice( bytes: { - AsyncStream { continuation in - if !inputBytes.isEmpty { - continuation.yield(inputBytes) + openStreams?.bytes + ?? AsyncStream { continuation in + if !inputBytes.isEmpty { + continuation.yield(inputBytes) + } + continuation.finish() } - continuation.finish() - } }, cellPixelSize: { await self.storedCellPixelSize }, enterAltScreen: { await self.enterAltScreen() }, @@ -56,6 +68,9 @@ public actor InMemoryTerminalDevice { exitAltScreen: { await self.exitAltScreen() }, exitRawMode: { await self.exitRawMode() }, size: { await self.storedSize }, + sizeChanges: { + openStreams?.sizeChanges ?? AsyncStream { $0.finish() } + }, write: { try await self.write($0) } ) } @@ -66,6 +81,24 @@ public actor InMemoryTerminalDevice { cellPixelSize: CellPixelSize? = nil, inputBytes: [UInt8] = [] ) { + self.init( + size: size, + cellPixelSize: cellPixelSize, + inputBytes: inputBytes, + keepsInputOpen: false + ) + } + + /// Creates a device whose input and size-change streams stay open until `closeInput()`. + /// + /// An open device can deliver `.resize` mid-session through `deliverResize(to:)`. + package init( + size: TerminalSize, + cellPixelSize: CellPixelSize?, + inputBytes: [UInt8], + keepsInputOpen: Bool + ) { + self.keepsInputOpen = keepsInputOpen self.storedInputBytes = inputBytes self.storedCellPixelSize = cellPixelSize self.storedSize = size @@ -76,6 +109,45 @@ public actor InMemoryTerminalDevice { self.storedSize = size } + /// Finishes the input and size-change streams of an open device. + package func closeInput() { + isInputClosed = true + for continuation in inputContinuations { + continuation.finish() + } + inputContinuations = [] + for continuation in sizeContinuations { + continuation.finish() + } + sizeContinuations = [] + } + + /// Reports a new size and emits it on the size-change streams of an open device. + package func deliverResize(to size: TerminalSize) { + storedSize = size + for continuation in sizeContinuations { + continuation.yield(size) + } + } + + private func makeOpenStreams( + inputBytes: [UInt8] + ) -> (bytes: AsyncStream<[UInt8]>, sizeChanges: AsyncStream) { + let bytes = AsyncStream.makeStream(of: [UInt8].self) + let sizeChanges = AsyncStream.makeStream(of: TerminalSize.self) + if !inputBytes.isEmpty { + bytes.continuation.yield(inputBytes) + } + if isInputClosed { + bytes.continuation.finish() + sizeChanges.continuation.finish() + } else { + inputContinuations.append(bytes.continuation) + sizeContinuations.append(sizeChanges.continuation) + } + return (bytes.stream, sizeChanges.stream) + } + private func enterAltScreen() { recordedEvents.append(.enterAltScreen) storedBytes.append(contentsOf: "\u{1B}[?1049h".utf8) diff --git a/Sources/TesseraTerminalTestSupport/InMemoryTerminalSession.swift b/Sources/TesseraTerminalTestSupport/InMemoryTerminalSession.swift index f29aabc..9d80055 100644 --- a/Sources/TesseraTerminalTestSupport/InMemoryTerminalSession.swift +++ b/Sources/TesseraTerminalTestSupport/InMemoryTerminalSession.swift @@ -32,6 +32,11 @@ public struct InMemoryTerminalSession: Sendable { ) } + /// Creates a session around an existing device, such as one that stays open for resizes. + package init(device: InMemoryTerminalDevice) { + self.device = device + } + /// Runs a body in an application terminal scope backed by this in-memory device. /// /// The terminal scope restores every acquired application mode before this method diff --git a/Tests/TesseraCoreTests/ViewGraphClockTests.swift b/Tests/TesseraCoreTests/ViewGraphClockTests.swift new file mode 100644 index 0000000..7431b76 --- /dev/null +++ b/Tests/TesseraCoreTests/ViewGraphClockTests.swift @@ -0,0 +1,58 @@ +import TesseraTerminalCore +import TesseraTestSupport +import Testing + +@testable import TesseraCore + +@Test +func `injected clock supplies every graph pass duration`() { + let size = TerminalSize(columns: 4, rows: 1) + let origin = ContinuousClock.now + var readings = 0 + let steppingClock = { + readings += 1 + return origin.advanced(by: .milliseconds(10 * readings)) + } + let graph = ViewGraph(root: { Text("tick") }, size: size, now: steppingClock) + + graph.update() + expectDurations( + graph.statistics, update: .milliseconds(10), layout: .zero, render: .zero) + + _ = withTestFrame(size: size) { graph.render(into: $0) } + expectDurations( + graph.statistics, + update: .milliseconds(10), + layout: .milliseconds(10), + render: .milliseconds(10) + ) +} + +@Test +func `graphs sharing deterministic clocks produce equal diagnostics`() { + let size = TerminalSize(columns: 4, rows: 1) + let origin = ContinuousClock.now + let fixedClock = { origin } + func makeGraph() -> ViewGraph { + ViewGraph(root: { Text("same") }, size: size, now: fixedClock) + } + let first = makeGraph() + let second = makeGraph() + + _ = withTestFrame(size: size) { first.render(into: $0) } + _ = withTestFrame(size: size) { second.render(into: $0) } + + #expect(first.diagnostics == second.diagnostics) + #expect(first.diagnostics.statistics.renderDuration == .zero) +} + +private func expectDurations( + _ statistics: GraphStatistics, + update: Duration, + layout: Duration, + render: Duration +) { + #expect(statistics.updateDuration == update) + #expect(statistics.layoutDuration == layout) + #expect(statistics.renderDuration == render) +} diff --git a/Tests/TesseraTerminalTests/TerminalSessionTests.swift b/Tests/TesseraTerminalTests/TerminalSessionTests.swift index 0171be5..8b36a3c 100644 --- a/Tests/TesseraTerminalTests/TerminalSessionTests.swift +++ b/Tests/TesseraTerminalTests/TerminalSessionTests.swift @@ -1,5 +1,6 @@ import CustomDump import TesseraTerminalANSI +import TesseraTerminalBuffer import TesseraTerminalCore import TesseraTerminalInput import TesseraTerminalTestSupport @@ -2571,6 +2572,261 @@ func `draw propagates flush errors`() async throws { } } +@Test +func `last drawn frame holds the buffer, cursor, and count of each flushed frame`() + async throws +{ + let size = TerminalSize(columns: 3, rows: 1) + let device = InMemoryTerminalDevice(size: size) + let session = await makeSession(device, synchronizedOutput: .disabled) + #expect(await session.lastDrawn == nil) + + try await session.draw { frame in + frame.write("abc", at: TerminalPosition(column: 0, row: 0)) + frame.setCursorPosition(TerminalPosition(column: 2, row: 0)) + } + var firstBuffer = Buffer(size: size) + firstBuffer.write("abc", at: TerminalPosition(column: 0, row: 0)) + #expect( + await session.lastDrawn + == DrawnFrame( + buffer: firstBuffer, + cursor: TerminalPosition(column: 2, row: 0), + sequence: 1 + ) + ) + + try await session.draw { frame in + frame.write("axc", at: TerminalPosition(column: 0, row: 0)) + } + await session.invalidateRenderer() + var secondBuffer = Buffer(size: size) + secondBuffer.write("axc", at: TerminalPosition(column: 0, row: 0)) + #expect( + await session.lastDrawn == DrawnFrame(buffer: secondBuffer, cursor: nil, sequence: 2) + ) +} + +@Test +func `a failed flush leaves the last drawn frame unchanged`() async throws { + let size = TerminalSize(columns: 1, rows: 1) + let device = FailOnceTerminalDevice(size: size) + let session = TerminalSession( + io: PlatformIO(terminalDevice: await device.terminalDevice)) + + await #expect(throws: PlatformIOError.writeFailed(errno: .ioError)) { + try await session.draw { frame in + frame.write("x", at: TerminalPosition(column: 0, row: 0)) + } + } + #expect(await session.lastDrawn == nil) + + try await session.draw { frame in + frame.write("y", at: TerminalPosition(column: 0, row: 0)) + } + var buffer = Buffer(size: size) + buffer.write("y", at: TerminalPosition(column: 0, row: 0)) + #expect( + await session.lastDrawn == DrawnFrame(buffer: buffer, cursor: nil, sequence: 1) + ) +} + +@Test +func `output tap receives each draw's bytes until it is cleared`() async throws { + let session = InMemoryTerminalSession(size: TerminalSize(columns: 3, rows: 1)) + let (tapped, tap) = AsyncStream.makeStream(of: [UInt8].self) + + let flushedWhileTapped = try await session.withApplicationTerminal { terminal in + terminal.outputTap = { tap.yield($0) } + try await terminal.draw { frame in + frame.write("abc", at: TerminalPosition(column: 0, row: 0)) + } + try await terminal.draw { frame in + frame.write("axc", at: TerminalPosition(column: 0, row: 0)) + } + let flushed = await session.events.filter(\.isFlush).map(\.flushBytes) + + terminal.outputTap = nil + try await terminal.draw { frame in + frame.write("xyz", at: TerminalPosition(column: 0, row: 0)) + } + return flushed + } + tap.finish() + var tappedBytes: [[UInt8]] = [] + for await bytes in tapped { + tappedBytes.append(bytes) + } + + #expect(flushedWhileTapped.count == 2) + expectNoDifference(tappedBytes, flushedWhileTapped) +} + +@Test +func `session size reads the terminal's current size`() async throws { + let device = InMemoryTerminalDevice(size: TerminalSize(columns: 4, rows: 2)) + let session = await makeSession(device) + #expect(try await session.size == TerminalSize(columns: 4, rows: 2)) + + await device.resize(to: TerminalSize(columns: 6, rows: 3)) + + #expect(try await session.size == TerminalSize(columns: 6, rows: 3)) +} + +@Test +func `resize delivered mid-session produces a second draw at the new size`() async throws +{ + let initialSize = TerminalSize(columns: 4, rows: 2) + let resizedSize = TerminalSize(columns: 6, rows: 3) + let device = InMemoryTerminalDevice( + size: initialSize, + cellPixelSize: nil, + inputBytes: [], + keepsInputOpen: true + ) + let session = InMemoryTerminalSession(device: device) + + let (drawnSizes, event, sizeChange, lastDrawn) = + try await session.withApplicationTerminal { terminal in + var sizeChanges = terminal.sizeChanges.makeAsyncIterator() + let first = try await terminal.draw { frame in + frame.write("a", at: TerminalPosition(column: 0, row: 0)) + return frame.size + } + + await device.deliverResize(to: resizedSize) + let event = try await terminal.nextEvent() + let sizeChange = await sizeChanges.next() + + let second = try await terminal.draw { frame in + frame.write("b", at: TerminalPosition(column: 5, row: 2)) + return frame.size + } + let lastDrawn = terminal.lastDrawn + await device.closeInput() + return ([first, second], event, sizeChange, lastDrawn) + } + + #expect(drawnSizes == [initialSize, resizedSize]) + #expect(event == .resize(resizedSize)) + #expect(sizeChange == resizedSize) + #expect(lastDrawn?.buffer.size == resizedSize) + #expect(lastDrawn?.sequence == 2) + #expect(await device.events.filter(\.isFlush).count >= 2) +} + +@Test +func `closed input ends the event stream of an open device`() async throws { + let device = InMemoryTerminalDevice( + size: TerminalSize(columns: 4, rows: 2), + cellPixelSize: nil, + inputBytes: [], + keepsInputOpen: true + ) + let session = InMemoryTerminalSession(device: device) + + await #expect(throws: PlatformIOError.inputClosed) { + try await session.withApplicationTerminal { terminal in + await device.closeInput() + _ = try await terminal.nextEvent() + } + } +} + +@Test +func `an open device keeps its input bytes ahead of later input and resizes`() + async throws +{ + let device = InMemoryTerminalDevice( + size: TerminalSize(columns: 4, rows: 2), + cellPixelSize: nil, + inputBytes: Array("x".utf8), + keepsInputOpen: true + ) + let session = InMemoryTerminalSession(device: device) + let resizedSize = TerminalSize(columns: 5, rows: 3) + + let events = try await session.withApplicationTerminal { terminal in + let first = try await terminal.nextEvent() + await device.deliverResize(to: resizedSize) + let second = try await terminal.nextEvent() + await device.closeInput() + return [first, second] + } + + #expect(events == [.key(Key(code: .character("x"))), .resize(resizedSize)]) +} + +@Test +func `injected probe image ID reaches the active Kitty graphics query`() async throws { + let device = KeyboardProbeResponseTerminalDevice( + size: TerminalSize(columns: 4, rows: 2)) + let io = PlatformIO(terminalDevice: await device.terminalDevice) + let configuration = TerminalApplicationConfiguration(capabilityDetection: .active) + + try await TerminalSession.withApplicationTerminal( + configuration: configuration, + io: io, + environment: [:], + probeImageID: KittyImageID(rawValue: 4_242), + probeSleep: probeSleepThatNeverElapses + ) { _ in } + + let flushes = await device.events.map(\.flushBytes) + let query = Array("\u{1B}_Gi=4242,s=1,v=1,a=q,t=d,f=24;AAAA\u{1B}\\\u{1B}[c".utf8) + #expect(flushes.contains(query)) +} + +@Test +func `injected probe sleep stands in for the probe timeout`() async throws { + let device = InMemoryTerminalDevice( + size: TerminalSize(columns: 4, rows: 2), + cellPixelSize: nil, + inputBytes: [], + keepsInputOpen: true + ) + let io = PlatformIO(terminalDevice: await device.terminalDevice) + let configuration = TerminalApplicationConfiguration( + activeProbeTimeout: .seconds(3_600), + capabilityDetection: .active + ) + let sleeps = ProbeSleepRecorder() + let recordingSleep: ActiveCapabilityProbeCoordinator.Sleep = { duration in + await sleeps.record(duration) + } + + try await TerminalSession.withApplicationTerminal( + configuration: configuration, + io: io, + environment: [:], + probeSleep: recordingSleep + ) { _ in } + + // The open device answers nothing, so the private-mode round and the keyboard round each + // wait on the timeout; an unanswered keyboard round skips the graphics round. A closed + // device would end every round at once, without reading the sleep. + let durations = await sleeps.durations + expectNoDifference(durations, [.seconds(3_600), .seconds(3_600)]) + await device.closeInput() +} + +/// Stands in for a probe sleep that never elapses: a round ends only by a reply, so a test +/// never waits on a clock. +private func probeSleepThatNeverElapses(_ duration: Duration) async throws { + let (stream, continuation) = AsyncStream.makeStream() + for await _ in stream {} + continuation.finish() + try Task.checkCancellation() +} + +private actor ProbeSleepRecorder { + private(set) var durations: [Duration] = [] + + func record(_ duration: Duration) { + durations.append(duration) + } +} + private func applicationModes( including additionalModes: Set = [] ) -> Set { From 46f0d65d636c04f0f380fdb55c9472ec30a41032 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Fri, 23 Oct 2026 17:41:01 -0400 Subject: [PATCH 03/37] docs(plans): record Phase 4 Slice 2 evidence, seam decisions, and discoveries --- docs/plans/phase-4-view-layer-and-developer-tooling.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 961f036..e981b16 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -15,7 +15,7 @@ This plan merges plan 032 (Phases 8-17, its open implementation phases; Phases 1 ## Progress - [ ] 1 Terminal colour reports -- [ ] 2 Seams for the harness +- [x] 2 Seams for the harness: `ea513af` on `phase-4/02-seams` (draft PR #59); `mise run core:test -- --filter TerminalSessionTests` runs 103 tests green (94 before, 9 seam tests added), and the two `ViewGraphClockTests` are green; full `mise run core:test` (673 tests), `quality:format`, `quality:lint`, and `docs:lint` green on Xcode 26.6; the added lines declare no `public` API (`git diff 79da704 HEAD -U0 -- Sources` shows only the existing `ViewGraph` init gaining `convenience`); CI run 40027319517 green on every leg (macOS, Linux, Windows, Swift 6.4, DocC, static Linux SDK). - [ ] 3 Harness target and Showcase retirement - [ ] 4 Demo contract, registry, viewports, and the headless driver - [ ] 5 Scenario language, runner, `tessera-dev`, and the CI corpus @@ -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 and Slice 2, in parallel, under [Unattended execution](#unattended-execution); Slice 3 follows Slice 2. Slice 4 waits for Decision 9 (i). +Next: Slice 1 runs in its own pull request. Slice 2 is done (PR #59). Slice 3 follows Slice 2 (branch from `phase-4/02-seams` until PR #59 lands) but names Decision 9 for the harness's product and stability promise, so it waits for the owner; Slice 4 waits for Decision 9 (i). ## Approach @@ -222,6 +222,7 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-23 Decision 5, the terminal colour-report probe: a slice of this plan (Slice 1) that depends on no other slice and runs in parallel, so styling is built and reviewed in the `derived` tier; the harness answers colour queries from the scenario's `term` header so goldens never depend on Ghostty's defaults. (owner) - 2026-10-23 Decision 8, the coverage walk: the levels `shortcut`, `tab`, and `unreachable`, the shortest route as key-and-count steps, and the stop reason, without Ternbook's app-only levels and landmarks. The rule behind it covers every tool: an output field earns its place when it lets an agent find and fix a problem in fewer turns with less context. (owner) - 2026-10-23 Slices renumbered: the colour-report slice is Slice 1, and Flow and `ViewThatFits` move from Slice 10 to Slice 7, so former Slices 1-5 are now 2-6, 6-9 are 8-11, 10 is 7, and 11-19 are 12-20; entries above use the new numbers. (this plan) +- 2026-10-23 Slice 2 seam shapes. `InMemoryTerminalDevice` registers an open device's byte and size-change streams inside the actor when `terminalDevice` is read, instead of the patch's unstructured attach `Task` with a replay of every delivered size: a resize delivered after the read is never lost to an attach race, and a late reader sees no history (plan 033 Step A.2's since-subscription semantics, without the race). The streams are single-reader, which `PlatformIO` satisfies (one `bytes()` and one `sizeChanges()` call). `TerminalSession.outputTap` tees only a draw's bytes, before its `io.write` (plan 033's "draw tap"); clipboard, image, probe, and cursor-restore writes stay untapped. `lastDrawn` sits beside the renderer's diff base rather than replacing it: `invalidateRenderer()` clears the base but not `lastDrawn`, because the terminal still shows that frame. `size` is `get async throws`, as `PlatformIO.size()` throws. `InMemoryTerminalSession.package init(device:)` from the patch stays, since an open device is only built through the package init. The `probeImageID` and `probeSleep` defaults are hoisted into `randomProbeImageID()` and `continuousClockSleep`, so the init and the `withApplicationTerminal` static share them. (this plan, Slice 2 run) - Open, Decision 9: how app authors consume the harness. (i) The public surface: `HeadlessApp` as designed here, or driving the Phase 5 runtime's scene (`TestRuntime(scene:)` in `docs/spec/phase-5-runtime-and-polish.md`), and which names carry a 1.0 stability promise. Deferred by the owner (2026-10-22); on 2026-10-23 the owner asked for a `senior` review (spikes allowed) to settle it before Slice 4, so Slices 4-7 can run unattended; the owner decides from that review. (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-dev`: 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. ## Discoveries @@ -231,6 +232,10 @@ Rules for an agent running slices of this plan without the owner. - `Text` does not wrap at HEAD and ignores its width proposal (plan 033) — Slice 8. - `handlerKinds` and the focus and requirement counters are dead at HEAD (plan 033; `focusChanges` confirmed in plan 034 R-10) — Slice 9. - The seams spike's `sizeChanges` fan-out was superseded by plan 034 Step 6.6, which fans one subscription out newest-only — Slice 2 adapts the patch rather than applying it. +- A device whose input has finished (the default `InMemoryTerminalDevice`) never waits on a probe timeout: `finishInput()` marks the coordinator closed and every round resolves at once, so an injected `probeSleep` is observable only on an open device. The seams README's "waits out `activeProbeTimeout`" holds for an input stream that never finishes. Slice 2's probe-sleep test uses `keepsInputOpen` for this reason. +- `--filter TerminalSessionTests`, the Slice 2 Verify command, matches the test's source file name, since the session tests are free-function `@Test`s; the seam tests live in `TerminalSessionTests.swift` so the filter runs them. +- `TerminalSession.outputTap` is actor-isolated state, so the harness sets it inside `withApplicationTerminal`'s `isolated` body or through an `isolated TerminalSession` parameter, never from outside the actor — Slice 3's `Driver` takes the session that way. +- swift-format rewrites a final closure argument into a trailing closure, then flags the call for also carrying a labelled closure (`OnlyOneTrailingClosureArgument`; SwiftLint `multiple_closures_with_trailing_closure`), so tests bind injected clocks and sleeps to locals before passing them. ## Outcome From 0bb02e712ca05a10d624fc74c34244877ce62a14 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Fri, 23 Oct 2026 22:30:20 -0400 Subject: [PATCH 04/37] refactor(terminal): draw in the caller's isolation Make TerminalSession.draw a nonisolated(nonsending) entry point, per the runtime isolation design in the Phase 5 spec proposal: the frame body runs in the caller's isolation, so a @MainActor host renders a non-Sendable graph where it lives, and the session serializes draws and commits the finished buffer on itself. sending is dropped from the body and the result. The draw splits into beginFrame (size and rendering policy, fixed once), the body in the caller's isolation, and commitFrame (encode, tee outputTap, write, flush, then set lastDrawn after a successful flush). The seam semantics are unchanged and nothing new is public. A @MainActor test host draws through an InMemoryTerminalDevice session; the same code did not compile against the sending signature. --- CHANGELOG.md | 1 + Sources/TesseraTerminal/TerminalSession.swift | 97 +++++++++++++------ .../TerminalSessionTests.swift | 38 ++++++++ ...hase-4-view-layer-and-developer-tooling.md | 1 + 4 files changed, 107 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa835a3..7cd704b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed - Changed `TerminalSession` to run its terminal teardown in an uncancellable task, so a cancelled body still leaves raw mode and the alternate screen, and to throw `TerminalSessionError.closed` from every writing call once `withApplicationTerminal` has returned; `events` and `nextEvent()` now drain one shared input queue, size changes keep only the newest value, and overlapping draws and flushes are serialized so each byte is sent once and frames diff against what the terminal received. +- Changed `TerminalSession.draw` to a `nonisolated(nonsending)` entry point: the frame body runs in the caller's isolation, so a `@MainActor` host (or any actor) renders a non-`Sendable` graph where it lives, while the session still serializes draws and commits the finished buffer; the body and the result are no longer `sending`. - Changed `ModeLifecycle` to close after a successful `exit()`: `enter` and `apply` then throw `ModeLifecycleError.closed` before writing; `exit()` stays retryable. `PlatformIO.flush` no longer retries `writeWouldBlock`; a `TerminalDevice` write either blocks until it makes progress or throws. - Changed the POSIX input loop to run on a dedicated thread with cancellation through its pipe, and bounded terminal write waits to 50 ms slices that honor task cancellation. - Changed layout containers to lay out `ForEach`, conditional, optional, and tuple children element by element instead of as one overlapping child. diff --git a/Sources/TesseraTerminal/TerminalSession.swift b/Sources/TesseraTerminal/TerminalSession.swift index fe1f41a..f00be69 100644 --- a/Sources/TesseraTerminal/TerminalSession.swift +++ b/Sources/TesseraTerminal/TerminalSession.swift @@ -107,6 +107,15 @@ public actor TerminalSession { case mouseTracking(MouseTrackingMode) } + /// The rendering policy fixed for one frame. + private struct FramePolicy: Sendable { + var size: TerminalSize + var colorCapability: ColorCapability + var hyperlinkRendering: HyperlinkRenderingMode + var synchronizedOutput: SynchronizedOutputPolicy + var underlineRendering: UnderlineRenderingPolicy + } + nonisolated private static let privateModeProbeModes = [ 2_004, 1_004, 1_002, 1_003, 1_006, 2_026, ] @@ -693,42 +702,62 @@ public actor TerminalSession { /// Draws one frame and flushes it to terminal output. /// - /// Concurrent draws run one at a time, so each frame diffs against the frame the - /// terminal last received. - public func draw( - _ body: (borrowing Frame) throws -> sending R - ) async throws -> sending R { - guard await drawGate.acquire() else { + /// The body runs in the caller's isolation, so a graph that lives with its host (the + /// main actor, another actor, or this session) renders where it lives; the session + /// serializes draws and commits the finished buffer. Concurrent draws run one at a + /// time, so each frame diffs against the frame the terminal last received. + nonisolated(nonsending) public func draw( + _ body: (borrowing Frame) throws -> R + ) async throws -> R { + guard await acquireDrawGate() else { throw CancellationError() } do { let result = try await drawHoldingGate(body) - await drawGate.release() + await releaseDrawGate() return result } catch { - await drawGate.release() + await releaseDrawGate() throw error } } - private func drawHoldingGate( - _ body: (borrowing Frame) throws -> sending R - ) async throws -> sending R { + private func acquireDrawGate() async -> Bool { + await drawGate.acquire() + } + + private func releaseDrawGate() async { + await drawGate.release() + } + + /// Reads the size and fixes the policy for the frame about to be rendered. + /// + /// This is the rendering policy commit point. It follows the size suspension and + /// precedes the synchronous frame body and encoding, so no setter can affect a frame + /// once its bytes begin to be constructed. + private func beginFrame() async throws -> FramePolicy { try checkOpen() let size = try await io.size() - // This is the rendering policy commit point. It follows the size suspension and - // precedes the synchronous frame body and encoding, so no setter can affect a frame - // once its bytes begin to be constructed. - let effectiveColorCapability = self.effectiveColorCapability - let hyperlinkRendering = self.hyperlinkRendering - let synchronizedOutput = self.synchronizedOutput - let underlineRendering = self.underlineRendering + return FramePolicy( + size: size, + colorCapability: effectiveColorCapability, + hyperlinkRendering: hyperlinkRendering, + synchronizedOutput: synchronizedOutput, + underlineRendering: underlineRendering + ) + } + + nonisolated(nonsending) private func drawHoldingGate( + _ body: (borrowing Frame) throws -> R + ) async throws -> R { + let policy = try await beginFrame() // The frame is a borrowed, non-escapable view onto heap-owned buffer storage. The - // storage outlives the synchronous body call, and the body runs without suspension, so - // the frame cannot escape the transaction or be observed by other actor work. + // storage outlives the synchronous body call, and the body runs without suspension in + // the caller's isolation, so the frame cannot escape the transaction or be observed by + // other work. let storage = UnsafeMutablePointer.allocate(capacity: 1) - storage.initialize(to: Buffer(size: size)) + storage.initialize(to: Buffer(size: policy.size)) defer { storage.deinitialize(count: 1) storage.deallocate() @@ -740,22 +769,31 @@ public actor TerminalSession { cursorStorage.deallocate() } let result = try body(Frame(buffer: storage, cursorPosition: cursorStorage)) - let buffer = storage.pointee + try await commitFrame(storage.pointee, cursor: cursorStorage.pointee, policy: policy) + return result + } + + /// Encodes the rendered buffer against the last drawn one and flushes the bytes. + private func commitFrame( + _ buffer: Buffer, + cursor: TerminalPosition?, + policy: FramePolicy + ) async throws { var bytes: [UInt8] = [] - if synchronizedOutput == .enabled { + if policy.synchronizedOutput == .enabled { ControlSequence.enterSynchronizedOutput.encode(into: &bytes) } renderer.encodeFrame( previous: lastDrawnBuffer, current: buffer, wrapInSynchronizedOutput: false, - colorCapability: effectiveColorCapability, - underlineRendering: underlineRendering, - renderHyperlinks: hyperlinkRendering == .enabled, + colorCapability: policy.colorCapability, + underlineRendering: policy.underlineRendering, + renderHyperlinks: policy.hyperlinkRendering == .enabled, into: &bytes ) - appendCursorState(cursorStorage.pointee, into: &bytes) - if synchronizedOutput == .enabled { + appendCursorState(cursor, into: &bytes) + if policy.synchronizedOutput == .enabled { ControlSequence.exitSynchronizedOutput.encode(into: &bytes) } outputTap?(bytes) @@ -765,10 +803,9 @@ public actor TerminalSession { lastDrawnBuffer = buffer lastDrawn = DrawnFrame( buffer: buffer, - cursor: cursorStorage.pointee, + cursor: cursor, sequence: (lastDrawn?.sequence ?? 0) + 1 ) - return result } catch { // A failed flush may have written a prefix of the frame, so the next successful draw // must erase and repaint conservatively. diff --git a/Tests/TesseraTerminalTests/TerminalSessionTests.swift b/Tests/TesseraTerminalTests/TerminalSessionTests.swift index 8b36a3c..a546e5f 100644 --- a/Tests/TesseraTerminalTests/TerminalSessionTests.swift +++ b/Tests/TesseraTerminalTests/TerminalSessionTests.swift @@ -2673,6 +2673,44 @@ func `session size reads the terminal's current size`() async throws { #expect(try await session.size == TerminalSize(columns: 6, rows: 3)) } +@Test @MainActor +func `a main-actor caller draws with a body that stays on the main actor`() async throws { + let size = TerminalSize(columns: 3, rows: 1) + let device = InMemoryTerminalDevice(size: size) + let session = await makeSession(device, synchronizedOutput: .disabled) + let host = MainActorFrameHost(text: "abc") + + let result = try await host.draw(to: session) + + var expectedBuffer = Buffer(size: size) + expectedBuffer.write("abc", at: TerminalPosition(column: 0, row: 0)) + #expect(result == 1) + #expect(host.drawCount == 1) + #expect(await session.lastDrawn?.buffer == expectedBuffer) + #expect(await device.events.filter(\.isFlush).count == 1) +} + +/// A host whose state is non-`Sendable` and lives on the main actor, standing in for a +/// view graph: its draw body runs on the main actor and needs no `Sendable` capture. +@MainActor +private final class MainActorFrameHost { + let text: String + private(set) var drawCount = 0 + + init(text: String) { + self.text = text + } + + func draw(to session: TerminalSession) async throws -> Int { + try await session.draw { frame in + MainActor.assertIsolated() + drawCount += 1 + frame.write(text, at: TerminalPosition(column: 0, row: 0)) + return drawCount + } + } +} + @Test func `resize delivered mid-session produces a second draw at the new size`() async throws { 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 e981b16..80a8362 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -223,6 +223,7 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-23 Decision 8, the coverage walk: the levels `shortcut`, `tab`, and `unreachable`, the shortest route as key-and-count steps, and the stop reason, without Ternbook's app-only levels and landmarks. The rule behind it covers every tool: an output field earns its place when it lets an agent find and fix a problem in fewer turns with less context. (owner) - 2026-10-23 Slices renumbered: the colour-report slice is Slice 1, and Flow and `ViewThatFits` move from Slice 10 to Slice 7, so former Slices 1-5 are now 2-6, 6-9 are 8-11, 10 is 7, and 11-19 are 12-20; entries above use the new numbers. (this plan) - 2026-10-23 Slice 2 seam shapes. `InMemoryTerminalDevice` registers an open device's byte and size-change streams inside the actor when `terminalDevice` is read, instead of the patch's unstructured attach `Task` with a replay of every delivered size: a resize delivered after the read is never lost to an attach race, and a late reader sees no history (plan 033 Step A.2's since-subscription semantics, without the race). The streams are single-reader, which `PlatformIO` satisfies (one `bytes()` and one `sizeChanges()` call). `TerminalSession.outputTap` tees only a draw's bytes, before its `io.write` (plan 033's "draw tap"); clipboard, image, probe, and cursor-restore writes stay untapped. `lastDrawn` sits beside the renderer's diff base rather than replacing it: `invalidateRenderer()` clears the base but not `lastDrawn`, because the terminal still shows that frame. `size` is `get async throws`, as `PlatformIO.size()` throws. `InMemoryTerminalSession.package init(device:)` from the patch stays, since an open device is only built through the package init. The `probeImageID` and `probeSleep` defaults are hoisted into `randomProbeImageID()` and `continuousClockSleep`, so the init and the `withApplicationTerminal` static share them. (this plan, Slice 2 run) +- 2026-10-23 `TerminalSession.draw` becomes `nonisolated(nonsending)` in Slice 2, ahead of Slice 4 (owner accepted it from the runtime isolation design proposed as `## Runtime isolation` in `docs/spec/phase-5-runtime-and-polish.md` on PR #61, with its evidence in `docs/research/runtime-isolation-evidence.md` there): the frame body runs in the caller's isolation, so a `@MainActor` host renders its non-`Sendable` graph where it lives, and the session serializes draws and commits on itself (`beginFrame` fixes the size and rendering policy, `commitFrame` encodes, tees `outputTap`, writes, flushes, then sets `lastDrawn`); `sending` is dropped from the body and the result. The seam semantics are unchanged: `lastDrawn` is set only after a successful flush, and `outputTap` still receives the draw's bytes before `io.write`. Nothing new is `public`, and the `isolated TerminalSession` loops of the examples compile as before. A `@MainActor` test host in `TerminalSessionTests` is the regression for the main-actor case: its body captures non-`Sendable` main-actor state, which did not compile against the `sending` signature. (owner, Slice 2 run) - Open, Decision 9: how app authors consume the harness. (i) The public surface: `HeadlessApp` as designed here, or driving the Phase 5 runtime's scene (`TestRuntime(scene:)` in `docs/spec/phase-5-runtime-and-polish.md`), and which names carry a 1.0 stability promise. Deferred by the owner (2026-10-22); on 2026-10-23 the owner asked for a `senior` review (spikes allowed) to settle it before Slice 4, so Slices 4-7 can run unattended; the owner decides from that review. (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-dev`: 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. ## Discoveries From 08174d28bd4df86e956ad22135aa17f1641f92fb Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Fri, 23 Oct 2026 23:42:51 -0400 Subject: [PATCH 05/37] fix(terminal): reject a draw that commits after its session closed The draw body now runs in the caller's isolation, so the scope that owns the session can close between beginFrame and the commit. commitFrame checks that the session is open before it encodes, taps, or writes, so a draw caught in that window throws TerminalSessionError.closed instead of painting a frame onto the restored terminal. Document that deliverResize(to:) also emits where the public resize(to:) only changes the reported size, amend the Phase 5 spec's draw transaction and plan 036's Slice 2 text to match, and narrow the Delivers line to the main-actor host the test uses. --- Sources/TesseraTerminal/TerminalSession.swift | 5 +++ .../InMemoryTerminalDevice.swift | 4 +++ .../TerminalSessionTests.swift | 36 +++++++++++++++++++ ...hase-4-view-layer-and-developer-tooling.md | 7 ++-- docs/spec/phase-5-runtime-and-polish.md | 2 +- 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/Sources/TesseraTerminal/TerminalSession.swift b/Sources/TesseraTerminal/TerminalSession.swift index f00be69..8a7930c 100644 --- a/Sources/TesseraTerminal/TerminalSession.swift +++ b/Sources/TesseraTerminal/TerminalSession.swift @@ -774,11 +774,16 @@ public actor TerminalSession { } /// Encodes the rendered buffer against the last drawn one and flushes the bytes. + /// + /// The body ran in the caller's isolation, so the scope that owns this session can have + /// closed since `beginFrame`; the check keeps a frame from reaching a terminal that has + /// already left its application modes. private func commitFrame( _ buffer: Buffer, cursor: TerminalPosition?, policy: FramePolicy ) async throws { + try checkOpen() var bytes: [UInt8] = [] if policy.synchronizedOutput == .enabled { ControlSequence.enterSynchronizedOutput.encode(into: &bytes) diff --git a/Sources/TesseraTerminalTestSupport/InMemoryTerminalDevice.swift b/Sources/TesseraTerminalTestSupport/InMemoryTerminalDevice.swift index c665ea7..fc6ae99 100644 --- a/Sources/TesseraTerminalTestSupport/InMemoryTerminalDevice.swift +++ b/Sources/TesseraTerminalTestSupport/InMemoryTerminalDevice.swift @@ -123,6 +123,10 @@ public actor InMemoryTerminalDevice { } /// Reports a new size and emits it on the size-change streams of an open device. + /// + /// Unlike `resize(to:)`, which changes only the size the device reports, this also + /// delivers a `.resize` event, so the reported size and the event stream agree. A closed + /// device has no size-change streams, so there it matches `resize(to:)`. package func deliverResize(to size: TerminalSize) { storedSize = size for continuation in sizeContinuations { diff --git a/Tests/TesseraTerminalTests/TerminalSessionTests.swift b/Tests/TesseraTerminalTests/TerminalSessionTests.swift index 04bc3ec..4eb4773 100644 --- a/Tests/TesseraTerminalTests/TerminalSessionTests.swift +++ b/Tests/TesseraTerminalTests/TerminalSessionTests.swift @@ -2711,6 +2711,42 @@ private final class MainActorFrameHost { } } +@Test @MainActor +func `a draw in flight across the scope's close throws closed and writes no frame`() + async throws +{ + let device = DelayedSizeTerminalDevice() + let io = PlatformIO(terminalDevice: await device.terminalDevice) + let configuration = TerminalApplicationConfiguration( + modes: [], + synchronizedOutput: .disabled, + colorCapability: .force(.noColor) + ) + let host = MainActorFrameHost(text: "abc") + + // The device holds the draw's size read, so the draw is in flight when the scope exits. + let drawing = try await TerminalSession.withApplicationTerminal( + configuration: configuration, + io: io, + environment: [:] + ) { terminal in + let drawing = Task { @MainActor in + try await host.draw(to: terminal) + } + await device.waitForSizeRequest() + return drawing + } + await device.resolveSize(TerminalSize(columns: 3, rows: 1)) + + await #expect(throws: TerminalSessionError.closed) { + try await drawing.value + } + // Only the scope's teardown reached the device (cursor restore, then the lifecycle's + // graphics cleanup): no frame followed it. + let teardownBytes = Array("\u{1B}[?25h".utf8) + kittyGraphicsDeleteAllBytes + #expect(await device.bytes == teardownBytes) +} + @Test func `a body isolated to the session draws in the session's own isolation`() async throws { 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 086c4d3..d77c1b8 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -15,7 +15,7 @@ This plan merges plan 032 (Phases 8-17, its open implementation phases; Phases 1 ## Progress - [ ] 1 Terminal colour reports -- [x] 2 Seams for the harness: seams `ea513af` and caller-isolated `draw` `0bb02e7` on `phase-4/02-seams` (PR #59); `mise run core:test -- --filter TerminalSessionTests` runs 105 tests green (94 before; 9 seam tests, a `@MainActor` draw test, and an `isolated TerminalSession` draw test added) and the two `ViewGraphClockTests` are green; full `mise run core:test` (675 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 40054450848 (`draw`) green on every leg, and the merged head's run is on the PR. +- [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. - [ ] 3 Harness target, `TesseraRuntime`, and Showcase retirement - [ ] 4 Registry, viewports, the main-actor driver, and `tessera launch` - [ ] 5 Scenario language, runner, the `tessera` CLI, and the CI corpus @@ -63,9 +63,9 @@ Delivers: a session against a terminal that answers fills `colorReport` before t ### 2 Seams for the harness -Plan 033 Step A.2. Apply `spikes/Seams/30e4bbf.patch` by hand against today's sources (it was written on `cb8d143`; plan 034 changed `PlatformIO` size fan-out and `TerminalSession` since): `InMemoryTerminalDevice` `package init(…keepsInputOpen:)`, `deliverResize(to:)`, `closeInput()`; `withApplicationTerminal` forwarding `probeImageID` and `probeSleep`; the `ViewGraph` clock seam as is; `TerminalSession` `package private(set) var lastDrawn: DrawnFrame?` set after a successful flush, `package var size`, and `package var outputTap` teed before `io.write`. Plan 034 already made `PlatformIO` fan one size subscription out newest-only, so the patch's fan-out hunk is replaced by the landed one, and `events`/`nextEvent()` already drain one queue. The slice also makes `TerminalSession.draw` caller-isolated, as the spec's [Runtime isolation](../spec/phase-5-runtime-and-polish.md#the-session) section specifies (2026-10-23 runtime isolation decision): `nonisolated(nonsending) public func draw(_ body: (borrowing Frame) throws -> R) async throws -> R`. The transaction is acquire the draw gate, begin the frame on the session (`checkOpen`, read the size, fix the rendering policy), run `body` synchronously in the caller's isolation, commit on the session (diff against the last drawn buffer, write, flush, record `lastDrawn`), release the gate; only the `Buffer` and cursor cross, as values. The signature loses `sending` on the body and the result, a relaxation, so every existing caller compiles unchanged. That is the only public line this slice changes; nothing new is `public`. +Plan 033 Step A.2. Apply `spikes/Seams/30e4bbf.patch` by hand against today's sources (it was written on `cb8d143`; plan 034 changed `PlatformIO` size fan-out and `TerminalSession` since): `InMemoryTerminalDevice` `package init(…keepsInputOpen:)`, `deliverResize(to:)`, `closeInput()`; `withApplicationTerminal` forwarding `probeImageID` and `probeSleep`; the `ViewGraph` clock seam as is; `TerminalSession` `package private(set) var lastDrawn: DrawnFrame?` set after a successful flush, `package var size`, and `package var outputTap` teed before `io.write`. Plan 034 already made `PlatformIO` fan one size subscription out newest-only, so the patch's fan-out hunk is replaced by the landed one, and `events`/`nextEvent()` already drain one queue. The slice also makes `TerminalSession.draw` caller-isolated, as the spec's [Runtime isolation](../spec/phase-5-runtime-and-polish.md#the-session) section specifies (2026-10-23 runtime isolation decision): `nonisolated(nonsending) public func draw(_ body: (borrowing Frame) throws -> R) async throws -> R`. The transaction is acquire the draw gate, begin the frame on the session (`checkOpen`, read the size, fix the rendering policy), run `body` synchronously in the caller's isolation, commit on the session (`checkOpen` again, since the scope can have closed while the body ran; diff against the last drawn buffer, write, flush, record `lastDrawn`), release the gate; only the `Buffer` and cursor cross, as values. The signature loses `sending` on the body and the result, a relaxation, so every existing caller compiles unchanged. That is the only public line this slice changes; nothing new is `public`. -Delivers: a mid-session `.resize` through `InMemoryTerminalDevice` redraws at the new size; `lastDrawn` and `outputTap` match the device's bytes; a `@MainActor` caller renders a `ViewGraph` through `draw` (the shape that failed with "sending value of non-Sendable type" before). Verify: `mise run core:test -- --filter TerminalSessionTests` with the seam tests from the patch adapted and green, plus a test that calls `draw` from a `@MainActor` function and one from `isolated TerminalSession`; `swift build --package-path Examples` still builds the `isolated TerminalSession` loops unchanged; `grep -rn "public" ` shows no new public API beyond `draw`'s relaxed signature. +Delivers: a mid-session `.resize` through `InMemoryTerminalDevice` redraws at the new size; `lastDrawn` and `outputTap` match the device's bytes; a `@MainActor` caller draws a non-`Sendable` main-actor host's frame through `draw` (the shape that failed with "sending value of non-Sendable type" before; `TesseraTerminalTests` cannot import `TesseraCore`, so Slice 4's driver tests cover rendering a `ViewGraph` on the main actor); a draw still in flight when its session's scope closes throws `TerminalSessionError.closed` and writes no frame. Verify: `mise run core:test -- --filter TerminalSessionTests` with the seam tests from the patch adapted and green, plus a test that calls `draw` from a `@MainActor` function and one from `isolated TerminalSession`; `swift build --package-path Examples` still builds the `isolated TerminalSession` loops unchanged; `grep -rn "public" ` shows no new public API beyond `draw`'s relaxed signature. ### 3 Harness target, `TesseraRuntime`, and Showcase retirement @@ -270,6 +270,7 @@ Rules for an agent running slices of this plan without the owner. - `--filter TerminalSessionTests`, the Slice 2 Verify command, matches the test's source file name, since the session tests are free-function `@Test`s; the seam tests live in `TerminalSessionTests.swift` so the filter runs them. - `TerminalSession.outputTap` is actor-isolated state, so the harness sets it inside `withApplicationTerminal`'s `isolated` body or through an `isolated TerminalSession` parameter, never from outside the actor — Slice 3's driver takes the session that way. - swift-format rewrites a final closure argument into a trailing closure, then flags the call for also carrying a labelled closure (`OnlyOneTrailingClosureArgument`; SwiftLint `multiple_closures_with_trailing_closure`), so tests bind injected clocks and sleeps to locals before passing them. swift-format also indents a modifier-only `nonisolated(nonsending)` line above a declaration, so the attribute sits inline with the other modifiers. +- A draw's body now runs in the caller's isolation, so the scope that owns the session can close between `beginFrame` and the commit; before, only the `io.size()` suspension left that window. `commitFrame` therefore checks that the session is open before it encodes, taps, or writes, and a draw caught in the window throws `TerminalSessionError.closed` with the renderer and `lastDrawn` untouched (critic review of PR #59, F1). The regression holds the draw at its size read, which opens the same window deterministically without a blocking body. `InMemoryTerminalDevice.resize(to:)` stays the silent public form and `deliverResize(to:)` the package form that also emits; the doc on `deliverResize` says so (F2). ## Outcome diff --git a/docs/spec/phase-5-runtime-and-polish.md b/docs/spec/phase-5-runtime-and-polish.md index 11e675d..e9213aa 100644 --- a/docs/spec/phase-5-runtime-and-polish.md +++ b/docs/spec/phase-5-runtime-and-polish.md @@ -161,7 +161,7 @@ nonisolated(nonsending) public func draw(_ body: (borrowing Frame) throws -> R) async throws -> R ``` -Its transaction, in order: acquire the draw gate (on the session); begin the frame on the session (`checkOpen`, read the size, fix the rendering policy for this frame: color capability, hyperlinks, synchronized output, underline); allocate the frame storage and run `body` synchronously in the caller's isolation; commit on the session (encode the diff against the last drawn buffer, write, flush, record `lastDrawn`); release the gate. The policy commit point keeps its meaning: it follows the size suspension and precedes the body, so no setter can affect a frame once its bytes begin to be constructed. The `Buffer` and cursor that cross into the commit are values. A caller inside the session's isolation (`isolated TerminalSession`, today's examples) runs the body on the session and hops nowhere; a main-actor caller renders on the main actor and hops once to commit. +Its transaction, in order: acquire the draw gate (on the session); begin the frame on the session (`checkOpen`, read the size, fix the rendering policy for this frame: color capability, hyperlinks, synchronized output, underline); allocate the frame storage and run `body` synchronously in the caller's isolation; commit on the session (`checkOpen` again, since the session's scope can have closed while the body ran; then encode the diff against the last drawn buffer, write, flush, record `lastDrawn`); release the gate. The policy commit point keeps its meaning: it follows the size suspension and precedes the body, so no setter can affect a frame once its bytes begin to be constructed. The `Buffer` and cursor that cross into the commit are values. A caller inside the session's isolation (`isolated TerminalSession`, today's examples) runs the body on the session and hops nowhere; a main-actor caller renders on the main actor and hops once to commit. Everything else in the session is unchanged: `events` is already a `nonisolated let` stream a main-actor loop consumes directly; `withApplicationTerminal` still scopes the session's isolation around its body. A caller-isolated `withApplicationTerminal` overload is a convenience this phase may add; the runtime does not need it. From 38cc7fd220ebfef563d828d0b2969afa6af97e40 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Fri, 23 Oct 2026 23:45:58 -0400 Subject: [PATCH 06/37] feat(harness): add TesseraRuntime with Application and TesseraHarness TesseraRuntime holds the app contract: Application refines only AnyObject, with body, apply(_:), and a defaulted isFinished, and its documentation carries the four adoption shapes. Tessera re-exports it, and TesseraRuntimeTests compiles each shape against import Tessera. TesseraHarness is a library product beside TesseraTestSupport. It carries the HeadlessRunner spike's driver, scenario parser, runner, report, and registry with its scenario corpus and tests, every public declaration now package. The spike stays under spikes/ as frozen evidence. Both targets get boundary allowlist rows and the shared swiftSettings, docs:targets and docs:merge know TesseraRuntime, and .gitattributes pins scenario files to LF. --- .gitattributes | 3 + Package.swift | 50 +++ Sources/Tessera/Tessera.docc/Tessera.md | 4 + Sources/Tessera/Tessera.swift | 1 + Sources/TesseraHarness/Demos.swift | 161 +++++++ Sources/TesseraHarness/Driver.swift | 80 ++++ Sources/TesseraHarness/Registry.swift | 84 ++++ Sources/TesseraHarness/Scenarios/CLI.swift | 151 +++++++ .../TesseraHarness/Scenarios/KeyName.swift | 101 +++++ .../TesseraHarness/Scenarios/Locator.swift | 101 +++++ Sources/TesseraHarness/Scenarios/Report.swift | 87 ++++ Sources/TesseraHarness/Scenarios/Runner.swift | 182 ++++++++ .../TesseraHarness/Scenarios/Scenario.swift | 399 ++++++++++++++++++ Sources/TesseraRuntime/Application.swift | 102 +++++ .../TesseraRuntime.docc/TesseraRuntime.md | 15 + Tests/TesseraHarnessTests/ScenarioTests.swift | 230 ++++++++++ .../Scenarios/failing/mismatch.scn | 10 + .../Scenarios/stack-mobile.scn | 15 + .../Scenarios/workspace-compact.scn | 15 + .../Scenarios/workspace-desktop.scn | 13 + .../Scenarios/workspace-input.scn | 19 + .../ApplicationTests.swift | 163 +++++++ docs/architecture/developer-tooling.md | 5 +- mise-tasks/docs/merge | 1 + mise-tasks/docs/targets | 1 + scripts/check-package-boundaries.py | 20 +- scripts/test_check_package_boundaries.py | 29 ++ spikes/HeadlessRunner/README.md | 2 + spikes/README.md | 4 +- 29 files changed, 2044 insertions(+), 4 deletions(-) create mode 100644 .gitattributes create mode 100644 Sources/TesseraHarness/Demos.swift create mode 100644 Sources/TesseraHarness/Driver.swift create mode 100644 Sources/TesseraHarness/Registry.swift create mode 100644 Sources/TesseraHarness/Scenarios/CLI.swift create mode 100644 Sources/TesseraHarness/Scenarios/KeyName.swift create mode 100644 Sources/TesseraHarness/Scenarios/Locator.swift create mode 100644 Sources/TesseraHarness/Scenarios/Report.swift create mode 100644 Sources/TesseraHarness/Scenarios/Runner.swift create mode 100644 Sources/TesseraHarness/Scenarios/Scenario.swift create mode 100644 Sources/TesseraRuntime/Application.swift create mode 100644 Sources/TesseraRuntime/TesseraRuntime.docc/TesseraRuntime.md create mode 100644 Tests/TesseraHarnessTests/ScenarioTests.swift create mode 100644 Tests/TesseraHarnessTests/Scenarios/failing/mismatch.scn create mode 100644 Tests/TesseraHarnessTests/Scenarios/stack-mobile.scn create mode 100644 Tests/TesseraHarnessTests/Scenarios/workspace-compact.scn create mode 100644 Tests/TesseraHarnessTests/Scenarios/workspace-desktop.scn create mode 100644 Tests/TesseraHarnessTests/Scenarios/workspace-input.scn create mode 100644 Tests/TesseraRuntimeTests/ApplicationTests.swift diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..866629e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Scenario files are parsed on "\n" and compared byte for byte, so every platform checks them +# out with LF, whatever core.autocrlf says (a Windows checkout would otherwise read "\r\n"). +*.scn text eol=lf diff --git a/Package.swift b/Package.swift index e7898d3..b27de06 100644 --- a/Package.swift +++ b/Package.swift @@ -122,6 +122,8 @@ let TesseraCore: Target.Dependency = .byName(name: "TesseraCore") let TesseraTestSupport: Target.Dependency = .byName(name: "TesseraTestSupport") let TesseraLayout: Target.Dependency = .byName(name: "TesseraLayout") let TesseraWidgets: Target.Dependency = .byName(name: "TesseraWidgets") +let TesseraRuntime: Target.Dependency = .byName(name: "TesseraRuntime") +let TesseraHarness: Target.Dependency = .byName(name: "TesseraHarness") let TesseraTerminal: Target.Dependency = .byName(name: "TesseraTerminal") let TesseraTerminalANSI: Target.Dependency = .byName(name: "TesseraTerminalANSI") let TesseraTerminalBuffer: Target.Dependency = .byName(name: "TesseraTerminalBuffer") @@ -144,6 +146,8 @@ let AllTesseraTargetNames: Set = [ "TesseraLayout", "TesseraTestSupport", "TesseraWidgets", + "TesseraRuntime", + "TesseraHarness", "TesseraTerminal", "TesseraTerminalANSI", "TesseraTerminalBuffer", @@ -221,6 +225,7 @@ package.targets.append( dependencies: [ TesseraCore, TesseraLayout, + TesseraRuntime, TesseraWidgets, TesseraTerminal, ] @@ -303,6 +308,24 @@ package.targets.append(contentsOf: [ ), ]) +// MARK: TesseraRuntime + +package.targets.append(contentsOf: [ + .target( + name: "TesseraRuntime", + dependencies: [ + TesseraCore, + TesseraTerminalInput, + ] + ), + .testTarget( + name: "TesseraRuntimeTests", + dependencies: [ + Tessera + ] + ), +]) + // MARK: TesseraTerminal package.products.append(.library(name: "TesseraTerminal", targets: ["TesseraTerminal"])) @@ -545,6 +568,33 @@ package.targets.append( ) ) +// MARK: TesseraHarness + +package.products.append(.library(name: "TesseraHarness", targets: ["TesseraHarness"])) + +package.targets.append(contentsOf: [ + .target( + name: "TesseraHarness", + dependencies: [ + TesseraCore, + TesseraLayout, + TesseraRuntime, + TesseraTerminalCore, + TesseraTerminalInput, + TesseraTerminalSnapshotSupport, + TesseraWidgets, + ] + ), + .testTarget( + name: "TesseraHarnessTests", + dependencies: [ + TesseraHarness, + TesseraTerminalInput, + ], + exclude: ["Scenarios"] + ), +]) + // MARK: - ⚙️ Shared Swift Settings for target in package.targets { diff --git a/Sources/Tessera/Tessera.docc/Tessera.md b/Sources/Tessera/Tessera.docc/Tessera.md index e2a2198..33367c9 100644 --- a/Sources/Tessera/Tessera.docc/Tessera.md +++ b/Sources/Tessera/Tessera.docc/Tessera.md @@ -21,6 +21,10 @@ Import `Tessera` to explore the shared foundation. Its application-programming s - ``/TesseraTerminal`` +### Application contract + +- ``/TesseraRuntime`` + ### Test support - ``/TesseraTestSupport`` diff --git a/Sources/Tessera/Tessera.swift b/Sources/Tessera/Tessera.swift index 758f640..6899b96 100644 --- a/Sources/Tessera/Tessera.swift +++ b/Sources/Tessera/Tessera.swift @@ -1,4 +1,5 @@ @_exported import TesseraCore @_exported import TesseraLayout +@_exported import TesseraRuntime @_exported import TesseraTerminal @_exported import TesseraWidgets diff --git a/Sources/TesseraHarness/Demos.swift b/Sources/TesseraHarness/Demos.swift new file mode 100644 index 0000000..45d50ae --- /dev/null +++ b/Sources/TesseraHarness/Demos.swift @@ -0,0 +1,161 @@ +import TesseraCore +import TesseraLayout +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` is a stub. +package final class WorkspaceApp: DemoApp { + package static let name = "workspace" + + private static let catalogRows = 30 + private static let catalogText = (0..: ScenarioHost { + private let app: App + private let graph: ViewGraph + private(set) var size: TerminalSize + private var screen = ScreenSnapshot.empty + + var diagnostics: GraphDiagnostics { + graph.diagnostics + } + + var screenRows: [String] { + screen.cells.map { row in + var text = String(row.map(\.character)) + while text.last == " " { + text.removeLast() + } + return text + } + } + + init(size: TerminalSize) { + let app = App() + app.apply(.resize(size)) + self.app = app + self.size = size + graph = ViewGraph(root: { app.root }, size: size) + present() + } + + func send(_ event: InputEvent) { + if case .resize(let newSize) = event { + size = newSize + graph.resize(to: newSize) + } + _ = graph.dispatch(event) + app.apply(event) + graph.update() + present() + } + + private func present() { + let graph = graph + screen = VirtualTerminal.snapshot(size: size) { frame in + graph.render(into: frame) + } + } +} diff --git a/Sources/TesseraHarness/Registry.swift b/Sources/TesseraHarness/Registry.swift new file mode 100644 index 0000000..afbb9b0 --- /dev/null +++ b/Sources/TesseraHarness/Registry.swift @@ -0,0 +1,84 @@ +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/CLI.swift b/Sources/TesseraHarness/Scenarios/CLI.swift new file mode 100644 index 0000000..6475718 --- /dev/null +++ b/Sources/TesseraHarness/Scenarios/CLI.swift @@ -0,0 +1,151 @@ +import Foundation + +// The command line's stdout is its product, so printing is the point here. +// swiftlint:disable no_print + +/// `headless run [--json] [--out DIR]` and `headless natural APP [--width N]`. +/// +/// Exit codes: 0 every scenario passed, 1 an expectation failed, 2 usage or parse error. +package enum CLI { + static let usage = """ + usage: + headless run [--json] [--out DIR] + headless natural [--width N] + headless help + apps: \(DemoRegistry.names.joined(separator: ", ")) + """ + + package static func main(arguments: [String]) -> Int32 { + guard let command = arguments.first else { + return usageError("missing command") + } + switch command { + case "run": + return run(Array(arguments.dropFirst())) + case "natural": + return natural(Array(arguments.dropFirst())) + case "help", "--help", "-h": + print(usage) + return 0 + default: + return usageError("unknown command `\(command)`; accepted: run, natural, help") + } + } + + private static func run(_ arguments: [String]) -> Int32 { + var files: [String] = [] + var json = false + var outDirectory: String? + var iterator = arguments.makeIterator() + while let argument = iterator.next() { + switch argument { + case "--json": + json = true + case "--out": + guard let directory = iterator.next() else { + return usageError("--out needs a directory") + } + outDirectory = directory + default: + if argument.hasPrefix("--") { + return usageError("unknown flag `\(argument)`; accepted: --json, --out") + } + files.append(argument) + } + } + guard !files.isEmpty else { + return usageError("run needs at least one scenario file") + } + + var exitCode: Int32 = 0 + for file in files { + let report: ScenarioReport + var artifacts: [ShotArtifact] = [] + do { + guard let data = FileManager.default.contents(atPath: file) else { + throw ScenarioParseError(file: file, line: 0, message: "cannot read file") + } + // swiftlint:disable:next optional_data_string_conversion + let source = String(decoding: data, as: UTF8.self) + let scenario = try ScenarioParser.parse(source, file: file) + (report, artifacts) = ScenarioRunner.execute(scenario, name: file) + if !report.passed { + exitCode = max(exitCode, 1) + } + } catch let error as ScenarioParseError { + report = ScenarioReport.parseFailure(error) + exitCode = 2 + } catch { + report = ScenarioReport.parseFailure( + ScenarioParseError(file: file, line: 0, message: "\(error)")) + exitCode = 2 + } + + print(json ? report.json() : report.human()) + if let outDirectory { + do { + try write(artifacts, for: file, into: outDirectory) + } catch { + FileHandle.standardError.write(Data("error: \(error)\n".utf8)) + exitCode = 2 + } + } + } + return exitCode + } + + private static func natural(_ arguments: [String]) -> Int32 { + var app: String? + var width = 40 + var iterator = arguments.makeIterator() + while let argument = iterator.next() { + if argument == "--width" { + guard let value = iterator.next(), let parsed = Int(value), parsed >= 1 else { + return usageError("--width needs a positive integer") + } + width = parsed + } else if app == nil { + app = argument + } else { + return usageError("unexpected argument `\(argument)`") + } + } + guard let app, let measured = DemoRegistry.naturalHeight(app: app, width: width) + else { + return usageError( + "natural needs a registered app; accepted: \(DemoRegistry.names.joined(separator: ", "))" + ) + } + print( + "\(app) width=\(width) probe=\(measured.probe.columns)x\(measured.probe.rows) " + + "root_rows=\(measured.rows) fills_proposal=\(measured.fillsProposal)") + return 0 + } + + /// Writes `//.txt` and `.tree.txt`, only when bytes differ. + private static func write( + _ artifacts: [ShotArtifact], for file: String, into directory: String + ) + throws + { + let stem = URL(fileURLWithPath: file).deletingPathExtension().lastPathComponent + let folder = URL(fileURLWithPath: directory).appendingPathComponent(stem) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + for artifact in artifacts { + for (suffix, text) in [("txt", artifact.text), ("tree.txt", artifact.tree)] { + let url = folder.appendingPathComponent("\(artifact.name).\(suffix)") + let data = Data(text.utf8) + if FileManager.default.contents(atPath: url.path) != data { + try data.write(to: url) + } + } + } + } + + private static func usageError(_ message: String) -> Int32 { + FileHandle.standardError.write(Data("error: \(message)\n\(usage)\n".utf8)) + return 2 + } +} + +// swiftlint:enable no_print diff --git a/Sources/TesseraHarness/Scenarios/KeyName.swift b/Sources/TesseraHarness/Scenarios/KeyName.swift new file mode 100644 index 0000000..43552c6 --- /dev/null +++ b/Sources/TesseraHarness/Scenarios/KeyName.swift @@ -0,0 +1,101 @@ +import TesseraTerminalInput + +/// Playwright-style key names (`Enter`, `ArrowLeft`, `Shift+Tab`, `Ctrl+C`, `Meta+k`). +enum KeyName { + struct Failure: Error { + var message: String + } + + static let named: [String: KeyCode] = [ + "ArrowDown": .down, + "ArrowLeft": .left, + "ArrowRight": .right, + "ArrowUp": .up, + "Backspace": .backspace, + "Delete": .delete, + "End": .end, + "Enter": .enter, + "Escape": .escape, + "Home": .home, + "PageDown": .pageDown, + "PageUp": .pageUp, + "Space": .character(" "), + "Tab": .tab, + ] + + static let modifiers: [String: Modifiers] = [ + "Alt": .alt, + "Control": .control, + "Ctrl": .control, + "Meta": .meta, + "Shift": .shift, + "Super": .super, + ] + + static var accepted: String { + let names = named.keys.sorted() + (1...12).map { "F\($0)" } + return + "\(names.joined(separator: ", ")), a single character; " + + "modifiers: \(modifiers.keys.sorted().joined(separator: ", ")) joined with `+`" + } + + static func parse(_ token: String) throws -> Key { + var rest = Substring(token) + var active: Modifiers = [] + while let plus = rest.firstIndex(of: "+"), plus != rest.startIndex, + rest.index(after: plus) != rest.endIndex, + let modifier = modifiers[String(rest[.. String { + var parts: [String] = [] + for (name, flag) in [ + ("Ctrl", Modifiers.control), ("Alt", .alt), ("Meta", .meta), ("Shift", .shift), + ] where key.modifiers.contains(flag) { + parts.append(name) + } + switch key.code { + case .character(let character): + parts.append(character == " " ? "Space" : String(character)) + case .tab: parts.append("Tab") + case .enter: parts.append("Enter") + case .escape: parts.append("Escape") + case .backspace: parts.append("Backspace") + case .delete: parts.append("Delete") + case .up: parts.append("ArrowUp") + case .down: parts.append("ArrowDown") + case .left: parts.append("ArrowLeft") + case .right: parts.append("ArrowRight") + case .home: parts.append("Home") + case .end: parts.append("End") + case .pageUp: parts.append("PageUp") + case .pageDown: parts.append("PageDown") + case .function(let number): parts.append("F\(number)") + default: parts.append("?") + } + return parts.joined(separator: "+") + } +} diff --git a/Sources/TesseraHarness/Scenarios/Locator.swift b/Sources/TesseraHarness/Scenarios/Locator.swift new file mode 100644 index 0000000..4e348c6 --- /dev/null +++ b/Sources/TesseraHarness/Scenarios/Locator.swift @@ -0,0 +1,101 @@ +import TesseraCore +import TesseraTerminalCore + +/// `key=value` node selector: `type=Text id=root/1/0 text="Submit" nth=2`. +/// +/// Every field filters; `nth` then picks one match by zero-based preorder position. +/// Without `nth` the locator must match exactly one node. +package struct Locator: Equatable, Sendable { + struct Failure: Error { + var message: String + } + + enum Resolution { + case node(NodeDiagnostics) + case none + case ambiguous([NodeDiagnostics]) + } + + static let accepted = "type, id, text, nth" + + package var type: String? + package var id: String? + package var text: String? + package var nth: Int? + + var description: String { + var terms: [String] = [] + if let type { terms.append("type=\(type)") } + if let id { terms.append("id=\(id)") } + if let text { terms.append("text=\"\(text)\"") } + if let nth { terms.append("nth=\(nth)") } + return terms.joined(separator: " ") + } + + static func parse(_ tokens: [String]) throws -> Self { + var locator = Self() + for token in tokens { + guard let equals = token.firstIndex(of: "=") else { + throw Failure( + message: "locator term `\(token)` is not key=value; accepted keys: \(accepted)") + } + let key = String(token[..= 0 else { + throw Failure(message: "nth must be a non-negative integer, got `\(value)`") + } + locator.nth = nth + default: + throw Failure(message: "unknown locator key `\(key)`; accepted: \(accepted)") + } + } + return locator + } + + /// `TesseraWidgets.ScrollView` → `ScrollView`. + static func baseTypeName(_ viewType: String) -> String { + let withoutGenerics = viewType.prefix { $0 != "<" } + return String(withoutGenerics.split(separator: ".").last ?? withoutGenerics) + } + + /// Screen text inside `frame`, rows joined by newline. + static func renderedText(in frame: Rect, rows: [String]) -> String { + var lines: [String] = [] + for row in frame.rowRange where rows.indices.contains(row) { + let characters = Array(rows[row]) + let lower = max(frame.origin.column, 0) + let upper = min(frame.maxColumn, characters.count) + lines.append(lower < upper ? String(characters[lower.. Resolution { + let matches = nodes.filter { node in + if let type, Self.baseTypeName(node.viewType) != type { + return false + } + if let id, node.identity.description != id { + return false + } + if let text, !Self.renderedText(in: node.frame, rows: screenRows).contains(text) { + return false + } + return true + } + if let nth { + return nth < matches.count ? .node(matches[nth]) : .none + } + switch matches.count { + case 0: return .none + case 1: return .node(matches[0]) + default: return .ambiguous(matches) + } + } +} diff --git a/Sources/TesseraHarness/Scenarios/Report.swift b/Sources/TesseraHarness/Scenarios/Report.swift new file mode 100644 index 0000000..a2772a4 --- /dev/null +++ b/Sources/TesseraHarness/Scenarios/Report.swift @@ -0,0 +1,87 @@ +import Foundation + +/// One failed statement. `parse` failures carry only `message`. +package struct ScenarioFailure: Codable, Equatable, Sendable { + package var line: Int + package var statement: String + package var verb: String + package var message: String + package var expected: String? + package var actual: String? + + /// Screen rows (trailing blanks trimmed, trailing empty rows dropped) at the failure. + package var screen: [String]? // swiftlint:disable:this discouraged_optional_collection +} + +/// Output of an observation statement (`shot`, `tree`). +package struct ScenarioObservation: Codable, Equatable, Sendable { + package var line: Int + package var verb: String + package var name: String? + package var lines: [String] +} + +/// A file a `shot` statement produces when the CLI runs with `--out`. +struct ShotArtifact: Equatable, Sendable { + var name: String + var text: String + var tree: String +} + +/// The single source of the human and JSON forms. +package struct ScenarioReport: Codable, Equatable, Sendable { + package var scenario: String + package var passed: Bool + package var failures: [ScenarioFailure] + package var observations: [ScenarioObservation] + + static func parseFailure(_ error: ScenarioParseError) -> Self { + Self( + scenario: error.file, + passed: false, + failures: [ + ScenarioFailure( + line: error.line, + statement: "", + verb: "parse", + message: error.description + ) + ], + observations: [] + ) + } + + /// Compact, sorted-key JSON on one line. + package func json() -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let data = (try? encoder.encode(self)) ?? Data() + // swiftlint:disable:next optional_data_string_conversion + return String(decoding: data, as: UTF8.self) + } + + package func human() -> String { + var lines = ["\(passed ? "PASS" : "FAIL") \(scenario)"] + for failure in failures { + if failure.verb == "parse" || failure.verb == "usage" { + lines.append(" \(failure.message)") + continue + } + lines.append(" line \(failure.line): \(failure.statement)") + lines.append(" \(failure.message)") + if let expected = failure.expected { + lines.append(" expected: \(expected)") + } + if let actual = failure.actual { + lines.append(" actual: \(actual)") + } + if let screen = failure.screen { + lines.append(" screen:") + for (row, text) in screen.enumerated() { + lines.append(" \(row)| \(text)") + } + } + } + return lines.joined(separator: "\n") + } +} diff --git a/Sources/TesseraHarness/Scenarios/Runner.swift b/Sources/TesseraHarness/Scenarios/Runner.swift new file mode 100644 index 0000000..9b036ab --- /dev/null +++ b/Sources/TesseraHarness/Scenarios/Runner.swift @@ -0,0 +1,182 @@ +import TesseraCore +import TesseraTerminalCore +import TesseraTerminalInput + +/// Executes a parsed scenario against a ``DemoRegistry`` app. +/// +/// A failing `expect` is recorded with the screen at that moment and the run continues. +package enum ScenarioRunner { + package static func run(_ scenario: Scenario, name: String) -> ScenarioReport { + execute(scenario, name: name).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 { + let report = ScenarioReport( + scenario: name, + passed: false, + failures: [ + ScenarioFailure( + line: 0, statement: "app \(scenario.app)", verb: error.verb, + message: error.message) + ], + observations: [] + ) + return (report, []) + } + + var artifacts: [ShotArtifact] = [] + var failures: [ScenarioFailure] = [] + var observations: [ScenarioObservation] = [] + + for line in scenario.lines { + func failure( + _ message: String, expected: String? = nil, actual: String? = nil + ) -> ScenarioFailure { + ScenarioFailure( + line: line.number, + statement: line.source, + verb: line.statement.verb, + message: message, + expected: expected, + actual: actual, + screen: screenForReport(host.screenRows) + ) + } + + switch line.statement { + case .resize(let columns, let rows): + host.send(.resize(TerminalSize(columns: columns, rows: rows))) + + case .key(let key): + host.send(.key(key)) + + case .type(let text): + for character in text { + host.send(.key(Key(code: .character(character)))) + } + + case .shot(let shotName): + let rows = host.screenRows + observations.append( + ScenarioObservation( + line: line.number, verb: "shot", name: shotName, + lines: screenForReport(rows))) + artifacts.append( + ShotArtifact( + name: shotName, + text: rows.joined(separator: "\n") + "\n", + tree: treeLines(host.diagnostics.nodes).joined(separator: "\n") + "\n")) + + case .tree: + observations.append( + ScenarioObservation( + line: line.number, verb: "tree", name: nil, + lines: treeLines(host.diagnostics.nodes))) + + case .expectText(let row, let text): + let rows = host.screenRows + if let row { + guard rows.indices.contains(row) else { + failures.append( + failure( + "row \(row) is outside the \(rows.count)-row screen", + expected: text, actual: nil)) + continue + } + if rows[row] != text { + failures.append( + failure( + "row \(row) text differs", expected: text, actual: rows[row])) + } + } else if !rows.contains(where: { $0.contains(text) }) { + failures.append( + failure( + "no screen row contains the text", expected: text, + actual: "(not found in \(rows.count) rows)")) + } + + case .expectNode(let locator, let frame): + let nodes = host.diagnostics.nodes + switch locator.resolve(in: nodes, screenRows: host.screenRows) { + case .node(let node): + let actual = FrameExpectation( + column: node.frame.origin.column, row: node.frame.origin.row, + columns: node.frame.size.columns, rows: node.frame.size.rows) + if actual != frame { + failures.append( + failure( + "frame of `\(locator.description)` differs", + expected: frame.description, actual: actual.description)) + } + case .none: + failures.append( + failure( + "locator `\(locator.description)` matches no node", + expected: frame.description, + actual: "(no node; types: \(distinctTypes(nodes)))")) + case .ambiguous(let matches): + failures.append( + failure( + "locator `\(locator.description)` matches \(matches.count) nodes; add nth=", + expected: frame.description, + actual: matches.map(\.identity.description).joined(separator: ", "))) + } + } + } + + 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. + static func screenForReport(_ rows: [String]) -> [String] { + var rows = rows + while rows.last?.isEmpty == true { + rows.removeLast() + } + return rows + } + + static func distinctTypes(_ nodes: [NodeDiagnostics]) -> String { + var seen: [String] = [] + for node in nodes { + let type = Locator.baseTypeName(node.viewType) + if !seen.contains(type) { + seen.append(type) + } + } + return seen.joined(separator: ", ") + } + + /// One line per node: indentation by depth, identity, base type, frame (and clip if it differs). + static func treeLines(_ nodes: [NodeDiagnostics]) -> [String] { + nodes.map { node in + let identity = node.identity.description + let depth = identity.split(separator: "/").count - 1 + var line = + String(repeating: " ", count: depth) + + "\(identity) \(Locator.baseTypeName(node.viewType)) " + + "frame=\(rectText(node.frame))" + if node.clip != node.frame { + line += " clip=\(rectText(node.clip))" + } + return line + } + } + + private static func rectText(_ rect: Rect) -> String { + "\(rect.origin.column),\(rect.origin.row),\(rect.size.columns)x\(rect.size.rows)" + } +} diff --git a/Sources/TesseraHarness/Scenarios/Scenario.swift b/Sources/TesseraHarness/Scenarios/Scenario.swift new file mode 100644 index 0000000..d8dac6f --- /dev/null +++ b/Sources/TesseraHarness/Scenarios/Scenario.swift @@ -0,0 +1,399 @@ +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: Equatable, Sendable { + package var column: Int + package var row: Int + package var columns: Int + package var rows: Int + + var description: String { + "\(column),\(row),\(columns)x\(rows)" + } +} + +/// One executable scenario statement. +package enum Statement: Equatable, Sendable { + case expectNode(Locator, FrameExpectation) + case expectText(row: Int?, text: String) + case key(Key) + case resize(columns: Int, rows: Int) + case shot(name: String) + case tree + case type(String) + + /// The verb as it appears in reports. + package var verb: String { + switch self { + case .expectNode: "expect node" + case .expectText: "expect text" + case .key: "key" + case .resize: "resize" + case .shot: "shot" + case .tree: "tree" + case .type: "type" + } + } +} + +/// A statement with its source position. +package struct ScenarioLine: Equatable, Sendable { + package var number: Int + package var source: String + package var statement: Statement +} + +/// 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``. + package var app: String + package var viewport: Viewport + package var lines: [ScenarioLine] +} + +/// A fail-fast parse error that renders as `FILE:LINE: error: message`. +package struct ScenarioParseError: Error, Equatable, Sendable, CustomStringConvertible { + package var file: String + package var line: Int + package var message: String + + package var description: String { + "\(file):\(line): error: \(message)" + } +} + +/// Parses the v0 scenario grammar. +/// +/// ```text +/// # header (before the first body statement) +/// app NAME size WxH | desktop | mobile | mobileScreen +/// # world and input +/// resize WxH key Tab | key Shift+Tab | key Ctrl+C type "hi" +/// # observation +/// shot NAME tree +/// # expectation +/// expect text ["ROW"] "…" expect node SEL frame X,Y,WxH +/// ``` +package enum ScenarioParser { + /// Verbs the plan reserves for later phases; named so the error is specific. + static let deferredVerbs: Set = [ + "bytes", "check", "dump", "focus", "mouse", "paste", "wait", + ] + + static let acceptedVerbs = + "app, size, resize, key, type, shot, tree, expect text, expect node" + + package static func parse( + _ source: String, + file: String, + knownApps: [String] = DemoRegistry.names + ) throws -> Scenario { + var app = DemoRegistry.defaultName + var viewport = Viewport.desktop + var lines: [ScenarioLine] = [] + var shotNames: Set = [] + var sawApp = false + var sawSize = false + + let rawLines = source.split(separator: "\n", omittingEmptySubsequences: false) + for (offset, rawLine) in rawLines.enumerated() { + let number = offset + 1 + func fail(_ message: String) -> ScenarioParseError { + ScenarioParseError(file: file, line: number, message: message) + } + + let tokens: [String] + do { + tokens = try Tokenizer.tokens(in: String(rawLine)) + } catch let error as Tokenizer.Failure { + throw fail(error.message) + } + guard let verb = tokens.first else { + continue + } + let arguments = Array(tokens.dropFirst()) + let text = String(rawLine).trimmingCharacters(in: .whitespaces) + + switch verb { + case "app": + guard lines.isEmpty else { + throw fail( + "`app` is a header statement and must precede the first body statement") + } + guard !sawApp else { throw fail("duplicate `app` header") } + guard arguments.count == 1 else { throw fail("usage: app NAME") } + guard knownApps.contains(arguments[0]) else { + throw fail( + "unknown app `\(arguments[0])`; accepted: \(knownApps.joined(separator: ", "))" + ) + } + sawApp = true + app = arguments[0] + + case "size": + guard lines.isEmpty else { + throw fail( + "`size` is a header statement; use `resize WxH` after the first body statement" + ) + } + guard !sawSize else { throw fail("duplicate `size` header") } + guard arguments.count == 1 else { + throw fail("usage: size WxH | desktop | mobile | mobileScreen") + } + sawSize = true + switch arguments[0] { + case "desktop": viewport = .desktop + case "mobile": viewport = .mobile + case "mobileScreen": viewport = .mobileScreen + default: + guard let (columns, rows) = parseDimensions(arguments[0]) else { + throw fail( + "bad size `\(arguments[0])`; expected WxH, desktop, mobile, or mobileScreen" + ) + } + viewport = .custom(columns: columns, rows: rows) + } + + case "resize": + guard arguments.count == 1, let (columns, rows) = parseDimensions(arguments[0]) + else { + throw fail("usage: resize WxH (both at least 1)") + } + lines.append( + ScenarioLine( + number: number, source: text, statement: .resize(columns: columns, rows: rows) + )) + + case "key": + guard arguments.count == 1 else { + throw fail("usage: key NAME (e.g. key Shift+Tab)") + } + do { + let key = try KeyName.parse(arguments[0]) + lines.append(ScenarioLine(number: number, source: text, statement: .key(key))) + } catch let error as KeyName.Failure { + throw fail(error.message) + } + + case "type": + guard arguments.count == 1 else { throw fail("usage: type \"text\"") } + lines.append( + ScenarioLine(number: number, source: text, statement: .type(arguments[0]))) + + case "shot": + guard arguments.count == 1, isShotName(arguments[0]) else { + throw fail("usage: shot NAME (letters, digits, `.`, `_`, `-`)") + } + guard shotNames.insert(arguments[0]).inserted else { + throw fail("duplicate shot name `\(arguments[0])`") + } + lines.append( + ScenarioLine(number: number, source: text, statement: .shot(name: arguments[0])) + ) + + case "tree": + guard arguments.isEmpty else { + throw fail("`tree` takes no selector in v0") + } + lines.append(ScenarioLine(number: number, source: text, statement: .tree)) + + case "expect": + let statement = try parseExpect(arguments, fail: fail) + lines.append(ScenarioLine(number: number, source: text, statement: statement)) + + default: + if deferredVerbs.contains(verb) { + throw fail("`\(verb)` is not yet expressible in v0; accepted: \(acceptedVerbs)") + } + throw fail("unknown statement `\(verb)`; accepted: \(acceptedVerbs)") + } + } + + return Scenario(app: app, viewport: viewport, lines: lines) + } + + private static func parseExpect( + _ arguments: [String], + fail: (String) -> ScenarioParseError + ) throws -> Statement { + guard let kind = arguments.first else { + throw fail("usage: expect text … | expect node SEL frame X,Y,WxH") + } + let rest = Array(arguments.dropFirst()) + switch kind { + case "text": + switch rest.count { + case 1: + return .expectText(row: nil, text: rest[0]) + case 2: + guard let row = Int(rest[0]), row >= 0 else { + throw fail("expect text ROW must be a non-negative integer, got `\(rest[0])`") + } + return .expectText(row: row, text: rest[1]) + default: + throw fail("usage: expect text [ROW] \"…\"") + } + + case "node": + guard let frameIndex = rest.firstIndex(of: "frame") else { + throw fail( + "expect node needs `frame X,Y,WxH`; visible|clipped|count are not yet expressible" + ) + } + let selectorTokens = Array(rest[.. (Int, Int)? { + let parts = token.split(separator: "x", omittingEmptySubsequences: false) + guard parts.count == 2, let columns = Int(parts[0]), let rows = Int(parts[1]), + columns >= 1, rows >= 1 + else { + return nil + } + return (columns, rows) + } + + /// Accepts `X,Y,WxH` (one token) or the plan's `X,Y WxH` (two tokens). + static func parseFrame(_ tokens: [String]) -> FrameExpectation? { + let joined = tokens.joined(separator: ",") + let parts = joined.split(separator: ",", omittingEmptySubsequences: false) + guard parts.count == 3, + let column = Int(parts[0]), let row = Int(parts[1]), + let size = parseDimensionsAllowingZero(String(parts[2])) + else { + return nil + } + return FrameExpectation( + column: column, row: row, columns: size.0, rows: size.1) + } + + private static func parseDimensionsAllowingZero(_ token: String) -> (Int, Int)? { + let parts = token.split(separator: "x", omittingEmptySubsequences: false) + guard parts.count == 2, let columns = Int(parts[0]), let rows = Int(parts[1]), + columns >= 0, rows >= 0 + else { + return nil + } + return (columns, rows) + } + + private static func isShotName(_ name: String) -> Bool { + !name.isEmpty + && name.allSatisfy { + $0.isASCII && ($0.isLetter || $0.isNumber || "._-".contains($0)) + } + } +} + +/// Shell-style tokenizer: whitespace separated, `"…"` with escapes, `'…'` literal, +/// `#` starts a comment at a token boundary. +enum Tokenizer { + struct Failure: Error { + var message: String + } + + static func tokens(in line: String) throws -> [String] { + var tokens: [String] = [] + var current = "" + var inToken = false + var iterator = line.makeIterator() + var pending = iterator.next() + + func flush() { + if inToken { + tokens.append(current) + current = "" + inToken = false + } + } + + while let character = pending { + pending = iterator.next() + switch character { + case " ", "\t", "\r": + flush() + case "#" where !inToken: + pending = nil + case "\"": + inToken = true + var closed = false + while let next = pending { + pending = iterator.next() + if next == "\"" { + closed = true + break + } + if next == "\\" { + guard let escaped = pending else { + throw Failure(message: "dangling backslash in quoted string") + } + pending = iterator.next() + switch escaped { + case "n": current.append("\n") + case "t": current.append("\t") + case "\"", "\\": current.append(escaped) + default: + throw Failure( + message: "unknown escape `\\\(escaped)`; accepted: \\n \\t \\\" \\\\") + } + } else { + current.append(next) + } + } + guard closed else { throw Failure(message: "unterminated double quote") } + case "'": + inToken = true + var closed = false + while let next = pending { + pending = iterator.next() + if next == "'" { + closed = true + break + } + current.append(next) + } + guard closed else { throw Failure(message: "unterminated single quote") } + default: + inToken = true + current.append(character) + } + } + flush() + return tokens + } +} diff --git a/Sources/TesseraRuntime/Application.swift b/Sources/TesseraRuntime/Application.swift new file mode 100644 index 0000000..63ff38f --- /dev/null +++ b/Sources/TesseraRuntime/Application.swift @@ -0,0 +1,102 @@ +import TesseraCore +import TesseraTerminalInput + +/// An application that a Tessera host can drive: a reference type that owns its model and +/// describes its interface as a view. +/// +/// A host constructs the application, renders ``body`` through a view graph, and offers each +/// input event to the graph first. The host then calls ``apply(_:)`` for every event the +/// graph left unhandled, and for every resize, and stops after the step in which +/// ``isFinished`` becomes `true`. The application reads and writes its own model; there is no +/// separate state container to adopt. +/// +/// ## Isolation +/// +/// `Application` refines nothing but `AnyObject`. It is not `@MainActor`, and it does not +/// refine `SendableMetatype`, because either would rule out one of the shapes below. A +/// nonisolated application runs on any host. An application whose model is main-actor +/// isolated declares a main-actor isolated conformance, which only a main-actor host can use; +/// a host on any other actor rejects it at compile time, so no path needs a runtime check of +/// isolation. One application instance belongs to one host. +/// +/// ## Adopting Application +/// +/// Declare the conformance where the type is declared, so the type's isolation and its +/// conformance agree. There are four shapes. +/// +/// A plain class runs on any host: +/// +/// ```swift +/// final class Counter: 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 } +/// } +/// } +/// ``` +/// +/// An `@Observable` model is the same: observation lives on the model, not on the host. +/// +/// ```swift +/// @Observable final class Counter: Application { /* as above */ } +/// ``` +/// +/// A module that sets `.defaultIsolation(MainActor.self)` (the Xcode 26 app templates) needs +/// nothing more. Its types are main-actor isolated by inference, and so are their +/// conformances, so they run on a main-actor host and may use any other main-actor type in the +/// module. To let one type also run on other actors, mark it `nonisolated`. +/// +/// ```swift +/// final class Notes: Application { +/// let store = NotesStore() // another main-actor class in the module +/// var body: some View { Text("\(store.saved) saved") } +/// func apply(_ event: InputEvent) { store.save() } +/// } +/// +/// nonisolated final class Tool: Application { /* nonisolated members */ } +/// ``` +/// +/// A type with a main-actor model, such as a store that is itself `@MainActor`, declares the +/// conformance isolated: +/// +/// ```swift +/// @MainActor final class Browser: @MainActor Application { +/// let store: StoreOf +/// var body: some View { Text("\(store.items.count) items") } +/// func apply(_ event: InputEvent) { store.send(.input(event)) } +/// } +/// ``` +/// +/// When production code must stay free of the contract, a test or development target adopts it +/// with the isolation the type already has: `extension Counter: Application {}` for a +/// nonisolated type, and `extension Notes: @MainActor Application {}` for a main-actor one. +/// Writing the second without `@MainActor` is a compile error, not a runtime trap. +public protocol Application: AnyObject { + /// The type of the root view. + associatedtype Body: View + + /// The root view of the application. + /// + /// `body` reads the model and hands out `Binding`s, so controlled controls can write + /// back to it. Reading it must not change the model. + @ViewBuilder var body: Body { get } + + /// Whether the application has asked its host to stop. + /// + /// A host checks this after each step and stops once it is `true`. The default is + /// `false`. There is no host-owned quit key: an application that wants one matches the + /// key in ``apply(_:)`` and sets the state this property reads. + var isFinished: Bool { get } + + /// 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. + func apply(_ event: InputEvent) +} + +extension Application { + /// An application runs until it says otherwise. + public var isFinished: Bool { false } +} diff --git a/Sources/TesseraRuntime/TesseraRuntime.docc/TesseraRuntime.md b/Sources/TesseraRuntime/TesseraRuntime.docc/TesseraRuntime.md new file mode 100644 index 0000000..5eb0d2b --- /dev/null +++ b/Sources/TesseraRuntime/TesseraRuntime.docc/TesseraRuntime.md @@ -0,0 +1,15 @@ +# ``TesseraRuntime`` + +The application contract that Tessera's hosts drive. + +## Overview + +``Application`` is the one declaration this module makes today: a reference type that owns its model and describes its interface as a view. Hosts, such as the application runtime that arrives with Phase 5 and the developer-tools harness, construct an application, render its `body`, and deliver input events to it. + +Import `Tessera` to adopt it: `Tessera` re-exports this module, so one import declares a conforming type. + +## Topics + +### The application contract + +- ``Application`` diff --git a/Tests/TesseraHarnessTests/ScenarioTests.swift b/Tests/TesseraHarnessTests/ScenarioTests.swift new file mode 100644 index 0000000..d9d33fa --- /dev/null +++ b/Tests/TesseraHarnessTests/ScenarioTests.swift @@ -0,0 +1,230 @@ +import Foundation +import TesseraTerminalInput +import Testing + +@testable import TesseraHarness + +struct ScenarioParserTests { + @Test + func `header statements bind before the first body statement`() throws { + let scenario = try ScenarioParser.parse( + """ + # comment + app stack + size mobile + type "hi there" # trailing comment + """, + file: "t.scn" + ) + + #expect(scenario.app == "stack") + #expect(scenario.viewport == .mobile) + #expect(scenario.lines.map(\.statement) == [.type("hi there")]) + #expect(scenario.lines.first?.number == 4) + } + + @Test + func `size after a body statement names the resize verb`() { + let error = #expect(throws: ScenarioParseError.self) { + try ScenarioParser.parse("key Tab\nsize 10x10", file: "t.scn") + } + + #expect(error?.line == 2) + #expect(error?.description.hasPrefix("t.scn:2: error: ") == true) + #expect(error?.message.contains("resize") == true) + } + + @Test + func `unknown statements list the accepted vocabulary`() { + let error = #expect(throws: ScenarioParseError.self) { + try ScenarioParser.parse("jump 3", file: "t.scn") + } + + #expect(error?.message.contains("accepted: app, size, resize, key") == true) + } + + @Test + func `reserved verbs are reported as not yet expressible`() { + let error = #expect(throws: ScenarioParseError.self) { + try ScenarioParser.parse("mouse click 3,4", file: "t.scn") + } + + #expect(error?.message.contains("not yet expressible") == true) + } + + @Test + func `unknown key names list the accepted set`() { + let error = #expect(throws: ScenarioParseError.self) { + try ScenarioParser.parse("key Hyperspace", file: "t.scn") + } + + #expect(error?.message.contains("ArrowLeft") == true) + } + + @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 keys = scenario.lines.compactMap { line -> Key? in + if case .key(let key) = line.statement { key } else { nil } + } + + #expect(keys[0] == Key(code: .tab, modifiers: .shift)) + #expect(keys[1] == Key(code: .character("c"), modifiers: .control)) + #expect(keys[2] == Key(code: .function(5))) + #expect(keys[3] == Key(code: .character("+"), modifiers: .control)) + } + + @Test + func `frame expectations accept one token or the plan's 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" + ) + + #expect(scenario.lines[0].statement == scenario.lines[1].statement) + } + + @Test + func `unterminated quotes fail with the line number`() { + let error = #expect(throws: ScenarioParseError.self) { + try ScenarioParser.parse("\ntype \"oops", file: "t.scn") + } + + #expect(error?.line == 2) + } + + @Test + func `duplicate shot names are rejected`() { + #expect(throws: ScenarioParseError.self) { + try ScenarioParser.parse("shot a\nshot a", file: "t.scn") + } + } +} + +struct ScenarioRunnerTests { + @Test + func `a wrong row expectation reports expected actual and the screen`() throws { + let scenario = try ScenarioParser.parse( + "app stack\nsize 40x10\nexpect text 0 \"Stack demos\"", + file: "t.scn" + ) + + let report = ScenarioRunner.run(scenario, name: "t.scn") + + #expect(report.passed == false) + let failure = try #require(report.failures.first) + #expect(failure.expected == "Stack demos") + #expect(failure.actual == "Stack demo") + #expect(failure.screen?.first == "Stack demo") + } + + @Test + func `the run continues after a failed expectation`() throws { + let scenario = try ScenarioParser.parse( + """ + app stack + size 40x10 + expect text 0 "wrong" + type "x" + expect text 2 "Name: x" + expect text 5 "wrong again" + """, + file: "t.scn" + ) + + let report = ScenarioRunner.run(scenario, name: "t.scn") + + #expect(report.failures.map(\.line) == [3, 6]) + } + + @Test + func `a locator that matches nothing is a failure that names the node types`() throws { + let scenario = try ScenarioParser.parse( + "app stack\nsize 40x10\nexpect node type=Missing frame 0,0,1x1", + file: "t.scn" + ) + + let report = ScenarioRunner.run(scenario, name: "t.scn") + + #expect(report.failures.first?.actual?.contains("VStack") == true) + } + + @Test + func `an ambiguous locator asks for nth`() throws { + let scenario = try ScenarioParser.parse( + "app stack\nsize 40x10\nexpect node type=Text frame 0,0,1x1", + file: "t.scn" + ) + + let report = ScenarioRunner.run(scenario, name: "t.scn") + + #expect(report.failures.first?.message.contains("add nth=") == true) + } + + @Test + func `type feeds one key event per character into demo state`() throws { + let scenario = try ScenarioParser.parse( + "app stack\nsize 40x10\ntype \"Rob\"\nkey Backspace\nexpect text 2 \"Name: Ro\"", + file: "t.scn" + ) + + #expect(ScenarioRunner.run(scenario, name: "t.scn").passed) + } + + @Test + func `mobile fails for a demo that fills its proposal`() throws { + let scenario = try ScenarioParser.parse( + "app workspace\nsize mobile\nshot a", + file: "t.scn" + ) + + let report = ScenarioRunner.run(scenario, name: "t.scn") + + #expect(report.failures.first?.verb == "size") + } + + @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) + + #expect(stack?.rows == 6) + #expect(stack?.fillsProposal == false) + #expect(workspace?.fillsProposal == true) + } +} + +struct ScenarioCorpusTests { + private static let scenarios = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Scenarios") + + private func report(for relativePath: String) 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) + } + + @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) + + #expect(first.passed) + #expect(first.json() == second.json()) + } + + @Test + func `the failing scenario fails four expectations`() throws { + let report = try report(for: "failing/mismatch.scn") + + #expect(report.failures.map(\.line) == [6, 7, 8, 10]) + } +} diff --git a/Tests/TesseraHarnessTests/Scenarios/failing/mismatch.scn b/Tests/TesseraHarnessTests/Scenarios/failing/mismatch.scn new file mode 100644 index 0000000..aa09d39 --- /dev/null +++ b/Tests/TesseraHarnessTests/Scenarios/failing/mismatch.scn @@ -0,0 +1,10 @@ +# Deliberately failing: wrong text, wrong frame, an unmatched locator. The run continues past +# each failure; the node frame check in the middle passes. +app stack +size 40x10 + +expect text 0 "Stack demos" +expect text "no such text" +expect node id=root frame 0,0,40x6 +expect node type=Text nth=0 frame 0,0,10x1 +expect node type=Missing frame 0,0,1x1 diff --git a/Tests/TesseraHarnessTests/Scenarios/stack-mobile.scn b/Tests/TesseraHarnessTests/Scenarios/stack-mobile.scn new file mode 100644 index 0000000..c17672f --- /dev/null +++ b/Tests/TesseraHarnessTests/Scenarios/stack-mobile.scn @@ -0,0 +1,15 @@ +# `size mobile` is 40 columns at the natural height the driver measures from the graph. +app stack +size mobile + +expect node id=root frame 0,0,40x6 +expect text 0 "Stack demo" +expect text 2 "Name: -" +expect text 3 "left right" +expect text 5 "40 columns" +type "Rob" +expect text 2 "Name: Rob" +resize 60x8 +expect text 5 "60 columns" +expect node type=Spacer frame 4,3,51x0 +shot mobile diff --git a/Tests/TesseraHarnessTests/Scenarios/workspace-compact.scn b/Tests/TesseraHarnessTests/Scenarios/workspace-compact.scn new file mode 100644 index 0000000..1c243fc --- /dev/null +++ b/Tests/TesseraHarnessTests/Scenarios/workspace-compact.scn @@ -0,0 +1,15 @@ +# Resizing below 60 columns collapses the catalog pane; the inspector takes the width. +app workspace +size desktop + +resize 50x12 +expect text 0 "Workspace 50x12" +expect text 2 "Typed:" +expect node type=SplitView frame 0,2,50x8 +expect node id=root/index(2)/id(inspector) frame 0,2,50x8 +expect node id=root/index(2)/id(catalog) frame 0,2,0x0 +shot compact + +resize 80x24 +expect text 2 "Item 0 │Typed:" +shot restored diff --git a/Tests/TesseraHarnessTests/Scenarios/workspace-desktop.scn b/Tests/TesseraHarnessTests/Scenarios/workspace-desktop.scn new file mode 100644 index 0000000..a15ed00 --- /dev/null +++ b/Tests/TesseraHarnessTests/Scenarios/workspace-desktop.scn @@ -0,0 +1,13 @@ +# Desktop layout of the workspace demo: header, split, status line. +app workspace +size desktop + +expect text 0 "Workspace 80x24" +expect text "Item 0" +expect text 2 "Item 0 │Typed:" +expect text 23 "focus=catalog scroll=0" +expect node type=SplitView frame 0,2,80x20 +expect node id=root/index(4) frame 0,23,22x1 +expect node text="Typed:" type=Text frame 33,2,7x1 +shot desktop +tree diff --git a/Tests/TesseraHarnessTests/Scenarios/workspace-input.scn b/Tests/TesseraHarnessTests/Scenarios/workspace-input.scn new file mode 100644 index 0000000..48cf52f --- /dev/null +++ b/Tests/TesseraHarnessTests/Scenarios/workspace-input.scn @@ -0,0 +1,19 @@ +# key and type reach demo-owned state; ViewGraph.dispatch is a stub at HEAD. +app workspace +size 80x24 + +type "hi" +key Enter +key Enter +key Tab +key ArrowDown +key ArrowDown +key Ctrl+C + +expect text 2 "Item 2 │Typed: hi" +expect text 3 "Item 3 │Key: Ctrl+c" +expect text 4 "Item 4 │Count: 2" +expect text 23 "focus=inspector scroll=2 quit" +# The ScrollView content keeps its full height; only the clip is the viewport. +expect node id=root/index(2)/id(catalog)/index(0)/index(0) frame 0,0,7x30 +shot after-input diff --git a/Tests/TesseraRuntimeTests/ApplicationTests.swift b/Tests/TesseraRuntimeTests/ApplicationTests.swift new file mode 100644 index 0000000..fa16668 --- /dev/null +++ b/Tests/TesseraRuntimeTests/ApplicationTests.swift @@ -0,0 +1,163 @@ +import Observation +import Tessera +import Testing + +// These tests import only `Tessera`: that one import is enough to declare an `Application`. +// Each type below is one adoption shape from the `Application` documentation, so a shape that +// stops compiling fails here. + +/// A plain class declared with the conformance: runs on any host. +private final class PlainCounter: Application { + private(set) var count = 9 + + var body: some View { + VStack { + Text("Counter value is \(count)") + Text("+ to count") + } + } + + func apply(_ event: InputEvent) { + if case .key(let key) = event, key.code == .character("+") { + count += 1 + } + } +} + +/// An `@Observable` model: the same shape, with observation on the model. +@Observable +private final class ObservedCounter: 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 main-actor model with a main-actor isolated conformance: only a main-actor host can use it. +@MainActor +private final class MainActorCounter: @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 nonisolated type declared without the contract, adopted in an extension the way a test or +/// development target adopts a type whose production module stays free of it. +private final class BareCounter { + 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 + } + } +} + +extension BareCounter: Application {} + +/// An application that overrides the default `isFinished`. +private final class OneShot: Application { + private(set) var isFinished = false + + var body: some View { + Text("one shot") + } + + func apply(_ event: InputEvent) { + if case .key = event { + isFinished = true + } + } +} + +private let plus = InputEvent.key(Key(code: .character("+"))) + +/// The columns the root view measures to, which shows `body` reading the current model. +private func measuredColumns(of app: App) -> Int? { + let graph = ViewGraph(root: { app.body }, size: TerminalSize(columns: 40, rows: 4)) + graph.update() + graph.layoutIfNeeded() + return graph.diagnostics.nodes.first { $0.parentIdentity == nil }?.measuredSize?.columns +} + +@Test +func `a plain class adopts Application and reads its model in body`() { + let app = PlainCounter() + + #expect(measuredColumns(of: app) == 18) + app.apply(plus) + + #expect(measuredColumns(of: app) == 19) +} + +@Test +func `an Observable class adopts Application`() { + let app = ObservedCounter() + + #expect(measuredColumns(of: app) == 18) + app.apply(plus) + + #expect(measuredColumns(of: app) == 19) +} + +@Test @MainActor +func `a main-actor class adopts Application with an isolated conformance`() { + let app = MainActorCounter() + + #expect(measuredColumns(of: app) == 18) + app.apply(plus) + + #expect(measuredColumns(of: app) == 19) +} + +@Test +func `an extension adopts Application for a type declared without it`() { + let app = BareCounter() + + #expect(measuredColumns(of: app) == 18) + app.apply(plus) + + #expect(measuredColumns(of: app) == 19) +} + +@Test +func `isFinished defaults to false and a conformer can override it`() { + let counter = PlainCounter() + let oneShot = OneShot() + + #expect(counter.isFinished == false) + counter.apply(plus) + #expect(counter.isFinished == false) + + #expect(oneShot.isFinished == false) + oneShot.apply(plus) + #expect(oneShot.isFinished) +} + +@Test @MainActor +func `a main-actor application keeps the default isFinished`() { + let app = MainActorCounter() + + app.apply(plus) + + #expect(app.isFinished == false) +} diff --git a/docs/architecture/developer-tooling.md b/docs/architecture/developer-tooling.md index af46ec5..2087805 100644 --- a/docs/architecture/developer-tooling.md +++ b/docs/architecture/developer-tooling.md @@ -51,8 +51,9 @@ 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 | -| Headless driver (`Driver`) and the app contract (`Application`) | both | `TesseraHarness`; `Application` in `TesseraRuntime` | designed | -| Scenario language and runner | both | grammar in `Examples/Scenarios/README.md`, runner in `TesseraHarness` | designed | +| 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 | | Projection and locators | agents first | `TesseraHarness` | designed | | Invariant checks (`check`) | both | `TesseraHarness` | designed | diff --git a/mise-tasks/docs/merge b/mise-tasks/docs/merge index e04959d..30a3838 100755 --- a/mise-tasks/docs/merge +++ b/mise-tasks/docs/merge @@ -8,6 +8,7 @@ xcrun docc merge \ .build/doccarchives/targets/Tessera.doccarchive \ .build/doccarchives/targets/TesseraCore.doccarchive \ .build/doccarchives/targets/TesseraLayout.doccarchive \ + .build/doccarchives/targets/TesseraRuntime.doccarchive \ .build/doccarchives/targets/TesseraTestSupport.doccarchive \ .build/doccarchives/targets/TesseraTerminal.doccarchive \ .build/doccarchives/targets/TesseraTerminalANSI.doccarchive \ diff --git a/mise-tasks/docs/targets b/mise-tasks/docs/targets index bd9d3fc..1675c49 100755 --- a/mise-tasks/docs/targets +++ b/mise-tasks/docs/targets @@ -6,6 +6,7 @@ echo "▶ Building documentation for Tessera targets..." base_targets=( TesseraCore TesseraLayout + TesseraRuntime TesseraTestSupport TesseraTerminalANSI TesseraTerminalBuffer diff --git a/scripts/check-package-boundaries.py b/scripts/check-package-boundaries.py index b27c364..478d841 100755 --- a/scripts/check-package-boundaries.py +++ b/scripts/check-package-boundaries.py @@ -15,8 +15,26 @@ ), "TesseraLayout": frozenset({"TesseraCore"}), "TesseraWidgets": frozenset({"TesseraCore", "TesseraLayout"}), + "TesseraRuntime": frozenset({"TesseraCore", "TesseraTerminalInput"}), "Tessera": frozenset( - {"TesseraCore", "TesseraLayout", "TesseraTerminal", "TesseraWidgets"} + { + "TesseraCore", + "TesseraLayout", + "TesseraRuntime", + "TesseraTerminal", + "TesseraWidgets", + } + ), + "TesseraHarness": frozenset( + { + "TesseraCore", + "TesseraLayout", + "TesseraRuntime", + "TesseraTerminalCore", + "TesseraTerminalInput", + "TesseraTerminalSnapshotSupport", + "TesseraWidgets", + } ), } ALLOWED_DIRECT_DEPENDENCIES["TesseraTestSupport"] = frozenset( diff --git a/scripts/test_check_package_boundaries.py b/scripts/test_check_package_boundaries.py index 4ce740c..f3cb15f 100644 --- a/scripts/test_check_package_boundaries.py +++ b/scripts/test_check_package_boundaries.py @@ -34,12 +34,23 @@ def valid_package(self) -> dict[str, object]: ], "TesseraLayout": ["TesseraCore"], "TesseraWidgets": ["TesseraCore", "TesseraLayout"], + "TesseraRuntime": ["TesseraCore", "TesseraTerminalInput"], "Tessera": [ "TesseraCore", "TesseraLayout", + "TesseraRuntime", "TesseraTerminal", "TesseraWidgets", ], + "TesseraHarness": [ + "TesseraCore", + "TesseraLayout", + "TesseraRuntime", + "TesseraTerminalCore", + "TesseraTerminalInput", + "TesseraTerminalSnapshotSupport", + "TesseraWidgets", + ], "TesseraTestSupport": [ "TesseraCore", "TesseraTerminalTestSupport", @@ -65,6 +76,22 @@ def test_reports_each_forbidden_direct_edge_in_order(self) -> None: ], ) + def test_reports_forbidden_runtime_and_harness_edges(self) -> None: + package = self.valid_package() + targets = package["targets"] + 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") + + self.assertEqual( + checker.check_package_boundaries(package), + [ + "TesseraRuntime -> TesseraTerminalIO", + "TesseraHarness -> TesseraTerminal", + ], + ) + def test_reports_forbidden_product_dependencies(self) -> None: package = self.valid_package() targets = package["targets"] @@ -136,7 +163,9 @@ def test_main_reports_violations(self) -> None: "TesseraCore -> TesseraTerminalIO\n" "Missing required view-layer target: TesseraLayout\n" "Missing required view-layer target: TesseraWidgets\n" + "Missing required view-layer target: TesseraRuntime\n" "Missing required view-layer target: Tessera\n" + "Missing required view-layer target: TesseraHarness\n" "Missing required view-layer target: TesseraTestSupport\n", ) diff --git a/spikes/HeadlessRunner/README.md b/spikes/HeadlessRunner/README.md index 3b802b6..b789171 100644 --- a/spikes/HeadlessRunner/README.md +++ b/spikes/HeadlessRunner/README.md @@ -2,6 +2,8 @@ Plan 033, Spike 1. A standalone package that depends on Tessera by path (`../..`) and uses public API only. +The code has since been copied into the root package's `TesseraHarness` target and `TesseraHarnessTests` (plan 036 Slice 3), where it is maintained. This package stays as frozen evidence. + ## Hypothesis A scenario file with `size`, `resize`, `key`, `type`, `shot`, `expect text`, `expect node … frame`, and `tree` can drive a HEAD app (Text, stacks, ScrollView, SplitView) through `ViewGraph` + `VirtualTerminal.snapshot`, emit the JSON and exit-code contract byte-identically across two runs, and derive `mobile` natural height from `GraphDiagnostics` under a tall proposal. diff --git a/spikes/README.md b/spikes/README.md index 786c025..07e58ad 100644 --- a/spikes/README.md +++ b/spikes/README.md @@ -1,6 +1,6 @@ # Developer tooling spikes -Time-boxed spikes for plan 033 (`docs/plans/developer-tooling.md`). Each is a standalone SwiftPM package that depends on the root package by path (`../..`) and uses public API only, so anything it could not reach is itself a finding. Nothing in the root package imports these; plan 033 step A.3 migrates the useful code by copy. +Time-boxed spikes for plan 033 (`docs/plans/developer-tooling.md`). Each is a standalone SwiftPM package that depends on the root package by path (`../..`) and uses public API only, so anything it could not reach is itself a finding. Nothing in the root package imports these; plan 033 step A.3 migrates the useful code by copy, and nothing new goes into `spikes/`. | Spike | Question | Verdict | | --- | --- | --- | @@ -19,6 +19,8 @@ GhosttyDistribution is not a path-dependent package: it is the saved sources of The spikes are frozen evidence for plan 033: they are built by hand and stay out of CI (owner decision, 2026-10-21), so nothing guards them against drift in the root package. They were last built and tested on 2026-10-22 against the root package at that date, on both toolchain lanes (Xcode 26.6 and Xcode 27.1). +HeadlessRunner's code has been copied into the root package (`Sources/TesseraHarness` and `Tests/TesseraHarnessTests`, plan 036 Slice 3), where it is maintained from now on. The copy here stays as the record of what the spike proved and is no longer updated. + ## Running ```sh From 53b7a253ef31a0e06e222a4a96cd99c929f69b2c Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Fri, 23 Oct 2026 23:46:03 -0400 Subject: [PATCH 07/37] chore(examples): retire the Showcase Each view component ships its own demo instead of one monolithic app. Delete Examples/Sources/TesseraShowcase, its tests and Xcode schemes, the core:showcase task, and the manifest entries only the Showcase used, and point README.md, CONTRIBUTING.md, and docs/project-status.md at core:example. --- CONTRIBUTING.md | 2 +- .../TesseraShowcase (Attach).xcscheme | 80 ----- .../xcschemes/TesseraShowcase.xcscheme | 91 ------ Examples/Package.swift | 62 ---- .../TesseraShowcase/ShowcaseFixture.swift | 32 -- .../TesseraShowcase/ShowcaseModel.swift | 98 ------ .../TesseraShowcase/ShowcaseView.swift | 184 ----------- .../TesseraShowcase/TesseraShowcaseApp.swift | 62 ---- .../ShowcaseFixtureTests.swift | 296 ------------------ .../ShowcaseTerminalSessionTests.swift | 46 --- README.md | 9 +- docs/project-status.md | 2 +- mise-tasks/core/showcase | 5 - 13 files changed, 7 insertions(+), 962 deletions(-) delete mode 100644 Examples/.swiftpm/xcode/xcshareddata/xcschemes/TesseraShowcase (Attach).xcscheme delete mode 100644 Examples/.swiftpm/xcode/xcshareddata/xcschemes/TesseraShowcase.xcscheme delete mode 100644 Examples/Sources/TesseraShowcase/ShowcaseFixture.swift delete mode 100644 Examples/Sources/TesseraShowcase/ShowcaseModel.swift delete mode 100644 Examples/Sources/TesseraShowcase/ShowcaseView.swift delete mode 100644 Examples/Sources/TesseraShowcase/TesseraShowcaseApp.swift delete mode 100644 Examples/Tests/TesseraShowcaseTests/ShowcaseFixtureTests.swift delete mode 100644 Examples/Tests/TesseraShowcaseTests/ShowcaseTerminalSessionTests.swift delete mode 100755 mise-tasks/core/showcase diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d0ba99d..c5b7026 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,7 +100,7 @@ Tasks are grouped by area; run `mise tasks` to list them. The fastest inner-loop 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:showcase # run the Tessera Showcase example +mise run core:example # run an example from the Examples package ``` Run the narrowest relevant check first, then widen: diff --git a/Examples/.swiftpm/xcode/xcshareddata/xcschemes/TesseraShowcase (Attach).xcscheme b/Examples/.swiftpm/xcode/xcshareddata/xcschemes/TesseraShowcase (Attach).xcscheme deleted file mode 100644 index a2747ab..0000000 --- a/Examples/.swiftpm/xcode/xcshareddata/xcschemes/TesseraShowcase (Attach).xcscheme +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Examples/.swiftpm/xcode/xcshareddata/xcschemes/TesseraShowcase.xcscheme b/Examples/.swiftpm/xcode/xcshareddata/xcschemes/TesseraShowcase.xcscheme deleted file mode 100644 index b907ba4..0000000 --- a/Examples/.swiftpm/xcode/xcshareddata/xcschemes/TesseraShowcase.xcscheme +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Examples/Package.swift b/Examples/Package.swift index 546936b..ef9b2de 100644 --- a/Examples/Package.swift +++ b/Examples/Package.swift @@ -19,48 +19,16 @@ let package = Package( // MARK: - ⤵️ Dependencies -// 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" -) - // MARK: Tessera package.dependencies.append( .package(name: "tessera", path: "..") ) -let Tessera: Target.Dependency = .product( - name: "Tessera", - package: "tessera" -) - let TesseraTerminal: Target.Dependency = .product( name: "TesseraTerminal", package: "tessera" ) -let TesseraTerminalSnapshotSupport: Target.Dependency = .product( - name: "TesseraTerminalSnapshotSupport", - package: "tessera" -) -let TesseraTerminalTestSupport: Target.Dependency = .product( - name: "TesseraTerminalTestSupport", - package: "tessera" -) // MARK: - 🚛 Forward Module Declarations @@ -74,7 +42,6 @@ let Phase3ProtocolsDemoSupport: Target.Dependency = .byName( name: "Phase3ProtocolsDemoSupport" ) let RendererDemo: Target.Dependency = .byName(name: "RendererDemo") -let TesseraShowcase: Target.Dependency = .byName(name: "TesseraShowcase") let AllTesseraExampleTargetNames: Set = [ "ANSIEncoderDemo", @@ -85,7 +52,6 @@ let AllTesseraExampleTargetNames: Set = [ "Phase3ProtocolsDemo", "Phase3ProtocolsDemoSupport", "RendererDemo", - "TesseraShowcase", ] // MARK: - 🎯 Products & Targets @@ -192,34 +158,6 @@ package.targets.append( ) ) -// MARK: TesseraShowcase - -package.products.append( - .executable(name: "TesseraShowcase", targets: ["TesseraShowcase"]) -) - -package.targets.append(contentsOf: [ - .executableTarget( - name: "TesseraShowcase", - dependencies: [ - ExampleSupport, - Tessera, - ] - ), - .testTarget( - name: "TesseraShowcaseTests", - dependencies: [ - InlineSnapshotTesting, - SnapshotTesting, - TesseraShowcase, - Tessera, - TesseraTerminal, - TesseraTerminalSnapshotSupport, - TesseraTerminalTestSupport, - ] - ), -]) - // MARK: RendererDemo package.products.append(.executable(name: "RendererDemo", targets: ["RendererDemo"])) diff --git a/Examples/Sources/TesseraShowcase/ShowcaseFixture.swift b/Examples/Sources/TesseraShowcase/ShowcaseFixture.swift deleted file mode 100644 index 00b2ff6..0000000 --- a/Examples/Sources/TesseraShowcase/ShowcaseFixture.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Tessera - -public enum ShowcaseFixture: CaseIterable, Sendable { - case compact - case guardSize - case regular - case standard - case standardShort - - public var size: TerminalSize { - switch self { - case .regular: - TerminalSize(columns: 120, rows: 24) - case .standard: - TerminalSize(columns: 80, rows: 24) - case .standardShort: - TerminalSize(columns: 80, rows: 16) - case .compact: - TerminalSize(columns: 40, rows: 16) - case .guardSize: - TerminalSize(columns: 22, rows: 10) - } - } -} - -public struct ShowcaseScript: Equatable, Sendable { - public var events: [InputEvent] - - public init(events: [InputEvent]) { - self.events = events - } -} diff --git a/Examples/Sources/TesseraShowcase/ShowcaseModel.swift b/Examples/Sources/TesseraShowcase/ShowcaseModel.swift deleted file mode 100644 index 8bad100..0000000 --- a/Examples/Sources/TesseraShowcase/ShowcaseModel.swift +++ /dev/null @@ -1,98 +0,0 @@ -import Tessera - -enum ShowcaseCatalogSelection: String, Sendable { - case text = "Text" -} - -enum ShowcaseViewportRole: String, Sendable { - case compact = "one role" - case guardSize = "resize guard" - case regular = "three roles" - case standard = "two roles" -} - -/// App-owned Showcase state. Runtime nodes retain only ephemeral UI state. -final class ShowcaseModel { - private(set) var eventCount = 0 - private(set) var lastPaste = "" - private(set) var size: TerminalSize - var catalogSelection: ShowcaseCatalogSelection = .text - var controlValue = false - var isSpecimenVisible = true - var splitAxis: Axis = .horizontal - var widePanes = [ - SplitViewPane( - id: "catalog", - sizing: SplitViewPaneSizing(minimum: 23, requestedIdeal: 24, maximum: 24) - ), - SplitViewPane( - id: "playground", - sizing: SplitViewPaneSizing(minimum: 23, requestedIdeal: 70) - ), - SplitViewPane( - id: "inspector", - sizing: SplitViewPaneSizing(minimum: 23, requestedIdeal: 24, maximum: 24) - ), - ] - var standardPanes = [ - SplitViewPane( - id: "catalog", - sizing: SplitViewPaneSizing(minimum: 23, requestedIdeal: 24, maximum: 24) - ), - SplitViewPane( - id: "playground", - sizing: SplitViewPaneSizing(minimum: 23, requestedIdeal: 70) - ), - ] - var catalogOffset = TerminalPosition(column: 0, row: 0) - var playgroundOffset = TerminalPosition(column: 0, row: 0) - var inspectorOffset = TerminalPosition(column: 0, row: 0) - var compactOffset = TerminalPosition(column: 0, row: 0) - - var viewportRole: ShowcaseViewportRole { - if size.columns < 23 || size.rows < 10 { - return .guardSize - } - if size.columns < 48 { - return .compact - } - return size.columns < 73 ? .standard : .regular - } - - init(size: TerminalSize) { - self.size = size - } - - func dispatch(_ script: ShowcaseScript) { - for event in script.events { - dispatch(event) - } - } - - func dispatch(_ event: InputEvent) { - eventCount += 1 - switch event { - case .paste(let text): - lastPaste = text - case .resize(let size): - resize(to: size) - case .focusGained, .focusLost, .key, .kittyGraphicsResponse, - .kittyKeyboardEnhancementFlags, .mouse, .primaryDeviceAttributes, - .privateModeStatus, .unknown: - break - } - } - - func resize(to size: TerminalSize) { - self.size = size - } - - func binding( - _ keyPath: ReferenceWritableKeyPath - ) -> Binding { - Binding( - get: { self[keyPath: keyPath] }, - set: { self[keyPath: keyPath] = $0 } - ) - } -} diff --git a/Examples/Sources/TesseraShowcase/ShowcaseView.swift b/Examples/Sources/TesseraShowcase/ShowcaseView.swift deleted file mode 100644 index 3a31ba5..0000000 --- a/Examples/Sources/TesseraShowcase/ShowcaseView.swift +++ /dev/null @@ -1,184 +0,0 @@ -import Tessera - -private struct ShowcaseRoot: View { - let model: ShowcaseModel - - var body: some View { - if model.viewportRole == .guardSize { - Text("Resize to at least 23x10") - } else { - VStack { - HStack { - Text("Tessera Showcase") - Spacer() - Text(model.viewportRole.rawValue) - } - ShowcaseWorkspaceDivider(model: model) - switch model.viewportRole { - case .regular: - SplitView( - axis: model.binding(\.splitAxis), - panes: model.binding(\.widePanes) - ) { - ShowcaseCatalog(model: model) - .layoutPriority(1) - ShowcasePlayground(model: model) - ShowcaseInspector(model: model) - .layoutPriority(1) - } - case .standard: - SplitView( - axis: model.binding(\.splitAxis), - panes: model.binding(\.standardPanes) - ) { - ShowcaseCatalog(model: model) - .layoutPriority(1) - ShowcasePlayground(model: model) - } - case .compact: - ScrollView(.vertical, offset: model.binding(\.compactOffset)) { - ShowcaseCatalogContent(model: model) - } - case .guardSize: - EmptyView() - } - } - } - } -} - -private struct ShowcaseWorkspaceDivider: LeafView { - let model: ShowcaseModel - - func sizeThatFits( - _ proposal: ProposedSize, - state: inout Void, - environment: EnvironmentValues - ) -> TerminalSize { - TerminalSize(columns: proposal.width ?? 1, rows: 1) - } - - func render( - in region: inout RenderRegion, - state: inout Void, - environment: EnvironmentValues - ) { - let columns = max(region.bounds.size.columns, 0) - region.write( - String(repeating: "─", count: columns), - at: TerminalPosition(column: 0, row: 0) - ) - guard model.splitAxis == .horizontal else { - return - } - - switch model.viewportRole { - case .regular: - writeJunction(at: 24, in: ®ion) - writeJunction(at: columns - 25, in: ®ion) - case .standard: - writeJunction(at: 24, in: ®ion) - case .compact, .guardSize: - break - } - } - - private func writeJunction(at column: Int, in region: inout RenderRegion) { - guard column >= 0, column < region.bounds.size.columns else { - return - } - region.write("┬", at: TerminalPosition(column: column, row: 0)) - } -} - -private struct ShowcaseCatalog: View { - let model: ShowcaseModel - - var body: some View { - ScrollView(.vertical, offset: model.binding(\.catalogOffset)) { - ShowcaseCatalogContent(model: model) - } - } -} - -private struct ShowcaseCatalogContent: View { - let model: ShowcaseModel - - var body: some View { - Text( - """ - Catalog - > \(model.catalogSelection.rawValue) - Overview - Primitives - Divider - Frame - Padding - Spacer - Layout - HStack - VStack - ZStack - SplitView - Scrolling - ScrollView - Diagnostics - ViewGraph - Frames - Clips - Proposals - """ - ) - } -} - -private struct ShowcasePlayground: View { - let model: ShowcaseModel - - var body: some View { - ScrollView(.vertical, offset: model.binding(\.playgroundOffset)) { - Text( - """ - Playground - Selected: \(model.isSpecimenVisible ? model.catalogSelection.rawValue : "hidden") - - Text specimen - Hello, Tessera - Unicode: café 你好 - - [Button placeholder] - [Toggle placeholder: \(model.controlValue ? "on" : "off")] - - Layout diagnostics remain - visible through the Inspector. - """ - ) - } - } -} - -private struct ShowcaseInspector: View { - let model: ShowcaseModel - - var body: some View { - ScrollView(.vertical, offset: model.binding(\.inspectorOffset)) { - Text( - """ - Inspector - node: selected - proposal: \(model.size.columns)x\(model.size.rows) - frame: absolute - clip: parent - state: app-owned - render: ready - """ - ) - } - } -} - -extension ShowcaseModel { - func makeGraph() -> ViewGraph { - ViewGraph(root: { ShowcaseRoot(model: self) }, size: size) - } -} diff --git a/Examples/Sources/TesseraShowcase/TesseraShowcaseApp.swift b/Examples/Sources/TesseraShowcase/TesseraShowcaseApp.swift deleted file mode 100644 index 7edece0..0000000 --- a/Examples/Sources/TesseraShowcase/TesseraShowcaseApp.swift +++ /dev/null @@ -1,62 +0,0 @@ -import ExampleSupport -import Tessera - -@main -enum TesseraShowcase { - static func main() async throws { - guard TerminalExampleSupport.isRunningInInteractiveTerminal() else { - TerminalExampleSupport.printTerminalRequiredMessage( - applicationName: "TesseraShowcase", - features: ["raw mode", "terminal resize events", "immediate keyboard input"], - runCommand: "swift run --package-path Examples TesseraShowcase", - attachSchemeName: "TesseraShowcase (Attach)" - ) - return - } - - let configuration = TerminalApplicationConfiguration.default - try await TerminalSession.withApplicationTerminal( - configuration: configuration, - run - ) - } - - private static func run(terminal: isolated TerminalSession) async throws -> sending Void - { - let model = ShowcaseModel(size: ShowcaseFixture.standard.size) - let graph = model.makeGraph() - try await render(model, graph: graph, to: terminal) - - while true { - let event = try await terminal.nextEvent() - if case .key(let key) = event, key == Key(code: .character("q")) { - return - } - - _ = graph.dispatch(event) - model.dispatch(event) - if case .resize(let size) = event { - graph.resize(to: size) - } - graph.update() - if graph.needsRender { - try await render(model, graph: graph, to: terminal) - } - } - } - - static func render( - _ model: ShowcaseModel, - graph: ViewGraph, - to terminal: isolated TerminalSession - ) async throws { - try await terminal.draw { frame in - if model.size != frame.size { - model.resize(to: frame.size) - graph.resize(to: frame.size) - graph.update() - } - graph.render(into: frame) - } - } -} diff --git a/Examples/Tests/TesseraShowcaseTests/ShowcaseFixtureTests.swift b/Examples/Tests/TesseraShowcaseTests/ShowcaseFixtureTests.swift deleted file mode 100644 index ba79b38..0000000 --- a/Examples/Tests/TesseraShowcaseTests/ShowcaseFixtureTests.swift +++ /dev/null @@ -1,296 +0,0 @@ -import InlineSnapshotTesting -import SnapshotTesting -import Tessera -import TesseraTerminal -import TesseraTerminalSnapshotSupport -import TesseraTerminalTestSupport -import Testing -@testable import TesseraShowcase - -@Suite( - .disabled( - if: VirtualTerminal.isGhosttyUnavailable, - "Ghostty virtual terminal support is unavailable in this build." - ), - .serialized -) -struct ShowcaseFixtureTests { - @Test - func `regular fixture dispatches and renders deterministically`() { - let result = render(.regular) - assertModel(result.model, fixture: .regular) - assertInlineSnapshot(of: result.snapshot, as: .terminalText(trim: .trailing)) { - """ - Tessera Showcase three roles - ────────────────────────┬──────────────────────────────────────────────────────────────────────┬──────────────────────── - Catalog │Playground │Inspector - > Text │Selected: Text │node: selected - Overview │ │proposal: 120x24 - Primitives │Text specimen │frame: absolute - Divider │Hello, Tessera │clip: parent - Frame │Unicode: café 你好 │state: app-owned - Padding │ │render: ready - Spacer │[Button placeholder] │ - Layout │[Toggle placeholder: off] │ - HStack │ │ - VStack │Layout diagnostics remain │ - ZStack │visible through the Inspector. │ - SplitView │ │ - Scrolling │ │ - ScrollView │ │ - Diagnostics │ │ - ViewGraph │ │ - Frames │ │ - Clips │ │ - Proposals │ │ - │ │ - │ │ - """ - } - } - - @Test - func `standard fixture dispatches and renders deterministically`() { - let result = render(.standard) - assertModel(result.model, fixture: .standard) - assertInlineSnapshot(of: result.snapshot, as: .terminalText(trim: .trailing)) { - """ - Tessera Showcase three roles - ────────────────────────┬──────────────────────────────┬──────────────────────── - Catalog │Playground │Inspector - > Text │Selected: Text │node: selected - Overview │ │proposal: 80x24 - Primitives │Text specimen │frame: absolute - Divider │Hello, Tessera │clip: parent - Frame │Unicode: café 你好 │state: app-owned - Padding │ │render: ready - Spacer │[Button placeholder] │ - Layout │[Toggle placeholder: off] │ - HStack │ │ - VStack │Layout diagnostics remain │ - ZStack │visible through the Inspector.│ - SplitView │ │ - Scrolling │ │ - ScrollView │ │ - Diagnostics │ │ - ViewGraph │ │ - Frames │ │ - Clips │ │ - Proposals │ │ - │ │ - │ │ - """ - } - } - - @Test - func `short standard fixture dispatches and renders deterministically`() { - let result = render(.standardShort) - assertModel(result.model, fixture: .standardShort) - assertInlineSnapshot(of: result.snapshot, as: .terminalText(trim: .trailing)) { - """ - Tessera Showcase three roles - ────────────────────────┬──────────────────────────────┬──────────────────────── - Catalog │Playground │Inspector - > Text │Selected: Text │node: selected - Overview │ │proposal: 80x16 - Primitives │Text specimen │frame: absolute - Divider │Hello, Tessera │clip: parent - Frame │Unicode: café 你好 │state: app-owned - Padding │ │render: ready - Spacer │[Button placeholder] │ - Layout │[Toggle placeholder: off] │ - HStack │ │ - VStack │Layout diagnostics remain │ - ZStack │visible through the Inspector.│ - SplitView │ │ - Scrolling │ │ - """ - } - } - - @Test - func `compact fixture dispatches and renders deterministically`() { - let result = render(.compact) - assertModel(result.model, fixture: .compact) - assertInlineSnapshot(of: result.snapshot, as: .terminalText(trim: .trailing)) { - """ - Tessera Showcase one role - ──────────────────────────────────────── - Catalog - > Text - Overview - Primitives - Divider - Frame - Padding - Spacer - Layout - HStack - VStack - ZStack - SplitView - Scrolling - """ - } - } - - @Test - func `guard fixture dispatches and renders deterministically`() { - let result = render(.guardSize) - assertModel(result.model, fixture: .guardSize) - assertInlineSnapshot(of: result.snapshot, as: .terminalText(trim: .trailing)) { - """ - Resize to at least 23x - - - - - - - - - - """ - } - } - - @Test - func `responsive boundary snapshots preserve negotiated Showcase roles`() { - let sizes = [ - TerminalSize(columns: 22, rows: 10), - TerminalSize(columns: 23, rows: 10), - TerminalSize(columns: 47, rows: 10), - TerminalSize(columns: 48, rows: 10), - TerminalSize(columns: 72, rows: 10), - TerminalSize(columns: 73, rows: 10), - TerminalSize(columns: 119, rows: 24), - TerminalSize(columns: 120, rows: 24), - TerminalSize(columns: 121, rows: 24), - TerminalSize(columns: 73, rows: 9), - TerminalSize(columns: 73, rows: 10), - ] - let summary = sizes.map { size in - let model = ShowcaseModel(size: size) - let graph = model.makeGraph() - graph.layoutIfNeeded() - return [ - "\(size.columns)x\(size.rows)", - model.viewportRole.rawValue, - "catalog=\(region(named: "ShowcaseCatalog", in: graph))", - "playground=\(region(named: "ShowcasePlayground", in: graph))", - "inspector=\(region(named: "ShowcaseInspector", in: graph))", - ].joined(separator: " ") - }.joined(separator: "\n") - - assertInlineSnapshot(of: summary, as: .lines) { - """ - 22x10 resize guard catalog=- playground=- inspector=- - 23x10 one role catalog=0+23 playground=- inspector=- - 47x10 one role catalog=0+47 playground=- inspector=- - 48x10 two roles catalog=0+24 playground=25+23 inspector=- - 72x10 two roles catalog=0+24 playground=25+47 inspector=- - 73x10 three roles catalog=0+24 playground=25+23 inspector=49+24 - 119x24 three roles catalog=0+24 playground=25+69 inspector=95+24 - 120x24 three roles catalog=0+24 playground=25+70 inspector=96+24 - 121x24 three roles catalog=0+24 playground=25+71 inspector=97+24 - 73x9 resize guard catalog=- playground=- inspector=- - 73x10 three roles catalog=0+24 playground=25+23 inspector=49+24 - """ - } - } - - @Test - func `workspace divider uses T junctions at responsive pane boundaries`() throws { - let widths = [47, 48, 72, 73] - let summary = try widths.map { width in - let size = TerminalSize(columns: width, rows: 10) - let model = ShowcaseModel(size: size) - let graph = model.makeGraph() - let snapshot = VirtualTerminal.snapshot(size: size) { frame in - graph.render(into: frame) - } - let separator = try #require(snapshot.cells.dropFirst().first) - return "\(width): \(String(separator.map(\.character)))" - }.joined(separator: "\n") - - assertInlineSnapshot(of: summary, as: .lines) { - """ - 47: ─────────────────────────────────────────────── - 48: ────────────────────────┬─────────────────────── - 72: ────────────────────────┬─────────────────────────────────────────────── - 73: ────────────────────────┬───────────────────────┬──────────────────────── - """ - } - } - - @Test - func `model state and controlled ideals survive responsive role replacement`() { - let initialSize = TerminalSize(columns: 73, rows: 10) - let model = ShowcaseModel(size: initialSize) - let graph = model.makeGraph() - let widePanes = model.widePanes - let standardPanes = model.standardPanes - model.controlValue = true - model.isSpecimenVisible = false - model.catalogOffset = TerminalPosition(column: 0, row: 3) - graph.layoutIfNeeded() - - model.resize(to: TerminalSize(columns: 47, rows: 10)) - graph.resize(to: model.size) - graph.update() - graph.layoutIfNeeded() - #expect(model.viewportRole == .compact) - - model.resize(to: initialSize) - graph.resize(to: model.size) - graph.update() - graph.layoutIfNeeded() - - #expect(model.viewportRole == .regular) - #expect(model.controlValue) - #expect(model.isSpecimenVisible == false) - #expect(model.catalogOffset == TerminalPosition(column: 0, row: 3)) - #expect(model.widePanes == widePanes) - #expect(model.standardPanes == standardPanes) - } - - private func region(named name: String, in graph: ViewGraph) -> String { - let node = graph.diagnostics.nodes.first { node in - node.viewType.hasSuffix(name) - } - guard let node else { - if name == "ShowcaseCatalog", - let viewport = graph.diagnostics.nodes.first(where: { - $0.viewType.hasPrefix("TesseraWidgets.ScrollView<") - }) - { - return "\(viewport.clip.origin.column)+\(viewport.clip.size.columns)" - } - return "-" - } - return "\(node.clip.origin.column)+\(node.clip.size.columns)" - } - - private func render( - _ fixture: ShowcaseFixture - ) -> (model: ShowcaseModel, snapshot: ScreenSnapshot) { - let script = ShowcaseScript(events: [ - .paste("fixture"), - .resize(fixture.size), - ]) - let model = ShowcaseModel(size: fixture.size) - model.dispatch(script) - let graph = model.makeGraph() - let snapshot = VirtualTerminal.snapshot(size: fixture.size) { frame in - graph.render(into: frame) - } - return (model, snapshot) - } - - private func assertModel(_ model: ShowcaseModel, fixture: ShowcaseFixture) { - #expect(model.eventCount == 2) - #expect(model.lastPaste == "fixture") - #expect(model.size == fixture.size) - } -} diff --git a/Examples/Tests/TesseraShowcaseTests/ShowcaseTerminalSessionTests.swift b/Examples/Tests/TesseraShowcaseTests/ShowcaseTerminalSessionTests.swift deleted file mode 100644 index c64409f..0000000 --- a/Examples/Tests/TesseraShowcaseTests/ShowcaseTerminalSessionTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -import InlineSnapshotTesting -import SnapshotTesting -import Tessera -import TesseraTerminal -import TesseraTerminalTestSupport -import Testing -@testable import TesseraShowcase - -@Suite(.serialized) -struct ShowcaseTerminalSessionTests { - @Test - func `Showcase draws through an in-memory terminal session`() async throws { - let size = TerminalSize(columns: 20, rows: 6) - let initialSize = ShowcaseFixture.standard.size - #expect(size.columns < initialSize.columns) - #expect(size.rows < initialSize.rows) - let session = InMemoryTerminalSession(size: size) - let configuration = TerminalApplicationConfiguration( - modes: [.rawMode, .altScreen], - synchronizedOutput: .disabled, - colorCapability: .force(.noColor) - ) - let renderedSize = try await session.withApplicationTerminal( - configuration: configuration - ) { terminal in - let model = ShowcaseModel(size: initialSize) - let graph = model.makeGraph() - try await TesseraShowcase.render(model, graph: graph, to: terminal) - return model.size - } - - #expect(renderedSize == size) - let output = try #require( - String(bytes: await session.bytes, encoding: .utf8) - ).debugDescription - #expect(Array((await session.events).suffix(2)) == [.exitAltScreen, .exitRawMode]) - assertInlineSnapshot( - of: output, - as: .lines - ) { - #""" - "\u{1B}[?1049h\u{1B}[2J\u{1B}[1;1H\u{1B}[0mResize to at least 2\u{1B}[2;1H \u{1B}[3;1H \u{1B}[4;1H \u{1B}[5;1H \u{1B}[6;1H \u{1B}[0m\u{1B}[?25l\u{1B}[?25h\u{1B}_Ga=d,d=A\u{1B}\\\u{1B}[?1049l" - """# - } - } -} diff --git a/README.md b/README.md index a06db0b..f016727 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,10 @@ enum TerminalGreeting { } ``` -Want to see more from a checkout? Run the Showcase: +Want to see more from a checkout? Run one of the examples: ```sh -mise run core:showcase # or: swift run --package-path Examples TesseraShowcase +mise run core:example # pick an executable from the Examples package ``` ## Architecture @@ -149,10 +149,11 @@ 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). | ## Roadmap -The next body of work completes the view and application-programming layer, then uses it to exercise the substrate in integrated applications — including the Showcase app — and to broaden the OS versions Tessera supports and tests. See [Project status](docs/project-status.md). +The next body of work completes the view and application-programming layer, then uses it to exercise the substrate in integrated applications and to broaden the OS versions Tessera supports and tests. See [Project status](docs/project-status.md). ## Contributing @@ -161,7 +162,7 @@ Contributions are welcome — please read [CONTRIBUTING.md](CONTRIBUTING.md) fir ```sh mise run core:build # build the package mise run core:test # full Swift test suite -mise run core:showcase # run the Showcase example +mise run core:example # run an example from the Examples package mise run docs:preview # build and serve DocC locally mise run quality:format # auto-apply formatting mise run quality:lint # strict format + lint gate diff --git a/docs/project-status.md b/docs/project-status.md index 64b1274..8579b0a 100644 --- a/docs/project-status.md +++ b/docs/project-status.md @@ -28,7 +28,7 @@ Contributions that validate other OS versions, distributions, or architectures a ## Component Demos -Each view component will ship with a small standalone demo that shows what it does and how to use it. The demos also render headlessly at fixed sizes, so they double as the snapshot fixtures that test each component, and they will later be assembled into an introductory demo app. They replace the earlier plan for a single Showcase app, whose example is being retired. +Each view component will ship with a small standalone demo that shows what it does and how to use it. The demos also render headlessly at fixed sizes, so they double as the snapshot fixtures that test each component, and they will later be assembled into an introductory demo app. ## View Components (in development) diff --git a/mise-tasks/core/showcase b/mise-tasks/core/showcase deleted file mode 100755 index 7744586..0000000 --- a/mise-tasks/core/showcase +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Run the Tessera Showcase example directly" -set -euo pipefail -cd "$MISE_PROJECT_ROOT" -swift run --only-use-versions-from-resolved-file --package-path Examples TesseraShowcase From e5de1ab6a17b3b72462a8b3072fb2872f4713429 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Fri, 23 Oct 2026 23:53:59 -0400 Subject: [PATCH 08/37] docs(plans): record Slice 3, the runtime, harness, and Showcase retirement Check the Slice 3 box with its evidence, move Next: to Slice 4, record the copy-not-move, access, dependency, shim, and .gitattributes decisions the owner may want to weigh, add the discoveries, and update the changelog. --- CHANGELOG.md | 7 +++++-- .../phase-4-view-layer-and-developer-tooling.md | 16 ++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ab7621..36795a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - Established the Swift Package Manager foundation with the `Tessera` and `TesseraTerminal` libraries, Swift 6 strict concurrency, and the `swift-system` and `swift-displaywidth` dependencies. - Added repository quality and development tooling with pinned npm markup checks, codespell, swift-format, SwiftLint, Lefthook Conventional Commit validation, Just workflows, cross-platform CI, DocC validation, environment diagnostics, and local-state documentation. -- Added the Tessera design catalog and runnable Showcase, including component contracts, tokens, templates, wireframe validation, responsive compositions, and local diagnostics. -- Added the declarative view and layout foundation with acyclic Core, Layout, and Widgets targets; persistent `ViewGraph` reconciliation and diagnostics; environments and styled text; snapshot support; stacks, modifiers, Divider, SplitView, and ScrollView; Flex constraints and pane negotiation; and responsive Showcase layouts. +- Added the Tessera design catalog, including component contracts, tokens, templates, wireframe validation, responsive compositions, and local diagnostics. +- Added the declarative view and layout foundation with acyclic Core, Layout, and Widgets targets; persistent `ViewGraph` reconciliation and diagnostics; environments and styled text; snapshot support; stacks, modifiers, Divider, SplitView, and ScrollView; and Flex constraints and pane negotiation. - Added comprehensive DocC catalogs, articles, symbol extensions, icons, and card artwork across the Tessera module family. - Added semantic terminal protocols for bracketed paste, focus events, SGR mouse tracking, Kitty keyboard input, OSC 8 hyperlinks, and capability detection, with lifecycle management, parser coverage, and protocol demo panels. - Added Kitty Graphics Protocol support with APC encoding and parsing, image query/transmission/deletion APIs, frame-scoped placement, cell pixel geometry, cleanup, Ghostty-backed inspection, and a graphics demo panel. @@ -21,6 +21,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added deterministic mode lifecycle handling, semantic SGR underline variants, colored underlines, renderer transitions, compatibility behavior, snapshots, and exact-byte coverage. - 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 refines nothing but `AnyObject`, 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 yet. - 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. @@ -49,6 +51,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Removed - Removed the Frost Windows workflow (its scripts, `windows-frost:` tasks, guide, and the Homebrew `qemu`, `swtpm`, and `sshpass` dependencies); QEMU's Apple hypervisor backend aborts on the TPM device on macOS 27, and snapshots of the UTM guest replace Frost's disposable overlays. +- Removed the Showcase example (`Examples/Sources/TesseraShowcase`, its tests and Xcode schemes) and the `core:showcase` task: each view component ships its own demo instead, and `mise run core:example` runs what remains in the Examples package. ### Fixed - Fixed `Package.resolved` churn between toolchains: the resolved files (root, `Examples`, and the workspace) are resolved with Swift 6.4, whose graph adds swift-issue-reporting, and every repository entry point passes `--only-use-versions-from-resolved-file`, so Swift 6.3.3 no longer prunes that pin; each CI test leg fails when a resolved file is out of date or a run changes it. 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 086c4d3..c33fde0 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -16,7 +16,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` and caller-isolated `draw` `0bb02e7` on `phase-4/02-seams` (PR #59); `mise run core:test -- --filter TerminalSessionTests` runs 105 tests green (94 before; 9 seam tests, a `@MainActor` draw test, and an `isolated TerminalSession` draw test added) and the two `ViewGraphClockTests` are green; full `mise run core:test` (675 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 40054450848 (`draw`) green on every leg, and the merged head's run is on the PR. -- [ ] 3 Harness target, `TesseraRuntime`, and Showcase retirement +- [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); full `mise run core:test` runs 699 tests green on Xcode 26.6 (675 before; 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 (699 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 TBD. - [ ] 4 Registry, viewports, the main-actor driver, and `tessera launch` - [ ] 5 Scenario language, runner, the `tessera` CLI, and the CI corpus - [ ] 6 Projection, locators, invariants, cost, trace, and shots, with the first demos @@ -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. Slice 2 is done (PR #59: the harness seams and the caller-isolated `TerminalSession.draw`). Slice 3 can start once #59 merges; Slice 4 follows Slice 3. Decision 9 (i) is recorded below, so nothing in Slices 3-7 waits on the owner. +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 Slice 3 decisions marked "(owner to weigh)" are reversible and do not block Slice 4. ## Approach @@ -256,6 +256,14 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-23 The CLI: the namespace is `tessera`, not `tessera-dev`. `tessera apps` lists apps, `tessera launch NAME` runs one live, and `tessera run PATH...` runs scenarios; `shot`, `tree`, `check`, `coverage`, `conform`, `replay`, and `attach` keep their names under `tessera`, and the `mise run dev:*` mirrors stay. "Demo" stays the word for Tessera's own per-component example apps, but the CLI and the scenario header say `app`. The render directory follows the name: `.build/tessera/`. (owner) - 2026-10-23 Slice 2 seam shapes. `InMemoryTerminalDevice` registers an open device's byte and size-change streams inside the actor when `terminalDevice` is read, instead of the patch's unstructured attach `Task` with a replay of every delivered size: a resize delivered after the read is never lost to an attach race, and a late reader sees no history (plan 033 Step A.2's since-subscription semantics, without the race). The streams are single-reader, which `PlatformIO` satisfies (one `bytes()` and one `sizeChanges()` call). `TerminalSession.outputTap` tees only a draw's bytes, before its `io.write` (plan 033's "draw tap"); clipboard, image, probe, and cursor-restore writes stay untapped. `lastDrawn` sits beside the renderer's diff base rather than replacing it: `invalidateRenderer()` clears the base but not `lastDrawn`, because the terminal still shows that frame. `size` is `get async throws`, as `PlatformIO.size()` throws. `InMemoryTerminalSession.package init(device:)` from the patch stays, since an open device is only built through the package init. The `probeImageID` and `probeSleep` defaults are hoisted into `randomProbeImageID()` and `continuousClockSleep`, so the init and the `withApplicationTerminal` static share them. (this plan, Slice 2 run) - 2026-10-23 Slice 2 `draw` shape, implementing the runtime isolation entry above: `draw` and its helper `drawHoldingGate` are `nonisolated(nonsending)`, so the body runs in the caller's isolation; the session-isolated steps are the gate (`acquireDrawGate`, `releaseDrawGate`), `beginFrame` (open check, size, and the rendering policy as one `FramePolicy` value, still the commit point), and `commitFrame` (encode, tee `outputTap`, write, flush, then set the diff base and `lastDrawn` only after a successful flush). `lastDrawn` and `outputTap` keep the semantics of the entry above. Only the finished `Buffer`, the cursor position, and the policy cross the boundary; `Frame` never does. (this plan, Slice 2 run) +- 2026-10-24 Slice 3 copied the spike instead of moving it (owner to weigh). `spikes/README.md` and plan 033 Step A.3 call the migration a copy, the spike READMEs are frozen evidence of what each spike proved, and removing `spikes/HeadlessRunner` would delete its `Package.resolved`, which no slice changes. `Sources/TesseraHarness` and `Tests/TesseraHarnessTests` are the maintained copy, and both spike READMEs say so. If the owner wants the plan's "move" literally, removing the spike's `Sources`, `Tests`, `Scenarios`, and manifest is a mechanical follow-up. (this plan, Slice 3 run) +- 2026-10-24 What the harness took from the spike, and its access. The driver, the scenario parser, runner, report, key names, locators, the registry with natural-height probing, and the two spike fixtures (`StackApp`, `WorkspaceApp`) moved with the spike's 18 tests (changed only in their imports and the corpus path) and its `Scenarios/*.scn`, byte-identical. Every `public` became `package`, so the target declares no public name; `DemoApp` and `DemoRegistry`, which stand in for `Application` and `ApplicationRegistry`, stay `package` until Slice 4 replaces them, and the fixtures go with them or become registrations of `Application` types. The `headless` executable's `main.swift` did not move, since a root-package library hosts no executable and Slice 4's `TesseraDev` is the executable; `CLI.main(arguments:)` moved as `package` code that nothing calls yet, for Slice 4 to replace with `HarnessCLI.main(registry:)`. The corpus sits at `Tests/TesseraHarnessTests/Scenarios`, excluded from the test target. (this plan, Slice 3 run) +- 2026-10-24 `TesseraHarness` depends on the narrow modules it imports, not on the `Tessera` umbrella: `TesseraCore`, `TesseraLayout`, and `TesseraWidgets` (for the two fixtures), `TesseraRuntime`, `TesseraTerminalCore`, `TesseraTerminalInput`, and `TesseraTerminalSnapshotSupport`. That list is also its boundary allowlist row, following the layout spec's rule that internal code imports the narrower target it needs; Slice 4 widens the row for `TesseraTerminal` and the in-memory device. `TesseraRuntime` is a target that `Tessera` re-exports, not a product, as the layout spec's product list has it, and its DocC catalog has no icon or card art. (this plan, Slice 3 run) +- 2026-10-24 No per-OS `sources:` shims (owner to weigh). The slice text asks for platform code in per-OS `sources:` shims, but the moved code uses only Foundation APIs (`FileManager`, `FileHandle`, `URL`, `JSONEncoder`) that exist on macOS, Linux, and Windows, so no `#if` and no divergent file was needed. The first platform seam arrives with Slice 4's live runner, which needs the terminal device. (this plan, Slice 3 run) +- 2026-10-24 `.gitattributes` gains `*.scn text eol=lf` (owner to weigh). The parser splits on `"\n"`, and `"\r\n"` is one Swift `Character`, so a CRLF checkout (Git for Windows' default `core.autocrlf`) reads a whole scenario as one line and fails the moved corpus on the Windows leg. Decision 7 planned `.gitattributes` for Slice 5's corpus; this is its first entry, earlier than planned, and Slice 5's golden patterns extend it. (this plan, Slice 3 run) +- 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; a paired `no_print` disable around `CLI.swift`, whose stdout is its product; and targeted disables for `discouraged_optional_collection` (`ScenarioFailure.screen`, where nil is omitted from the JSON) and `optional_data_string_conversion` (two lossy UTF-8 decodes). (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. ## Discoveries @@ -270,6 +278,10 @@ Rules for an agent running slices of this plan without the owner. - `--filter TerminalSessionTests`, the Slice 2 Verify command, matches the test's source file name, since the session tests are free-function `@Test`s; the seam tests live in `TerminalSessionTests.swift` so the filter runs them. - `TerminalSession.outputTap` is actor-isolated state, so the harness sets it inside `withApplicationTerminal`'s `isolated` body or through an `isolated TerminalSession` parameter, never from outside the actor — Slice 3's driver takes the session that way. - swift-format rewrites a final closure argument into a trailing closure, then flags the call for also carrying a labelled closure (`OnlyOneTrailingClosureArgument`; SwiftLint `multiple_closures_with_trailing_closure`), so tests bind injected clocks and sleeps to locals before passing them. swift-format also indents a modifier-only `nonisolated(nonsending)` line above a declaration, so the attribute sits inline with the other modifiers. +- The spike's 18 tests pass at their new home on today's runtime without a changed expectation, so the corpus's identity strings (`root/index(2)/id(catalog)` and its siblings) and the natural-height probe are still valid after plan 034. +- `Tessera` does not re-export `Observation`, so the `@Observable` adoption-shape test imports it beside `Tessera`: the plan's "imports only `Tessera`" holds for every Tessera module, and an app author's `@Observable` model needs `import Observation` whatever else it adopts. +- A `"\r\n"` is one `Character`, so `"a\r\nb".split(separator: "\n")` yields one piece. Slice 5's parser should split on `\n` and tolerate `\r`, or normalise line endings, rather than rely on `.gitattributes` alone, because an app author's scenario files may be checked out with CRLF. +- SwiftLint in new files: a `disable:next` comment between a doc comment and its declaration orphans the doc comment (`orphaned_doc_comment`), so the disable goes on the declaration's own line (`disable:this`); a bare `disable` needs a later `enable` (`blanket_disable_command`). ## Outcome From 2c28bb682ca3625a516ca0ea49903ec4df235687 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 00:20:25 -0400 Subject: [PATCH 09/37] fix(harness): qualify the isolation docs, guard import Tessera, drop the unused CLI Qualify the Application docs and the spec: leaving @MainActor off an extension conformance is a compile error from another module and in plain Swift 6 mode, but a module with default main-actor isolation or InferIsolatedConformances infers the isolated conformance for its own types. Add Examples/Sources/ApplicationConsumer, a one-file target that imports only Tessera and declares an Application, so the Examples build fails if Tessera stops re-exporting TesseraRuntime. Remove the harness's CLI, which had no caller and no test; Slice 4 writes HarnessCLI fresh and the spike keeps the original. Record in plan Slice 4 that its driver snapshot test restores the graph-to-session-to-bytes coverage the Showcase test gave, and note the three findings under Discoveries. --- Examples/Package.swift | 18 +++ .../ApplicationConsumer.swift | 36 +++++ Sources/TesseraHarness/Scenarios/CLI.swift | 151 ------------------ Sources/TesseraRuntime/Application.swift | 6 +- ...hase-4-view-layer-and-developer-tooling.md | 13 +- docs/spec/phase-5-runtime-and-polish.md | 2 +- spikes/HeadlessRunner/README.md | 2 +- spikes/README.md | 2 +- 8 files changed, 70 insertions(+), 160 deletions(-) create mode 100644 Examples/Sources/ApplicationConsumer/ApplicationConsumer.swift delete mode 100644 Sources/TesseraHarness/Scenarios/CLI.swift diff --git a/Examples/Package.swift b/Examples/Package.swift index ef9b2de..14b222f 100644 --- a/Examples/Package.swift +++ b/Examples/Package.swift @@ -25,6 +25,11 @@ package.dependencies.append( .package(name: "tessera", path: "..") ) +let Tessera: Target.Dependency = .product( + name: "Tessera", + package: "tessera" +) + let TesseraTerminal: Target.Dependency = .product( name: "TesseraTerminal", package: "tessera" @@ -33,6 +38,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 ExampleSupport: Target.Dependency = .byName(name: "ExampleSupport") let HelloTessera: Target.Dependency = .byName(name: "HelloTessera") let InputInspector: Target.Dependency = .byName(name: "InputInspector") @@ -45,6 +51,7 @@ let RendererDemo: Target.Dependency = .byName(name: "RendererDemo") let AllTesseraExampleTargetNames: Set = [ "ANSIEncoderDemo", + "ApplicationConsumer", "ExampleSupport", "HelloTessera", "InputInspector", @@ -70,6 +77,17 @@ package.targets.append( ) ) +// MARK: ApplicationConsumer + +package.targets.append( + .target( + name: "ApplicationConsumer", + dependencies: [ + Tessera + ] + ) +) + // MARK: ExampleSupport package.targets.append( diff --git a/Examples/Sources/ApplicationConsumer/ApplicationConsumer.swift b/Examples/Sources/ApplicationConsumer/ApplicationConsumer.swift new file mode 100644 index 0000000..47e7195 --- /dev/null +++ b/Examples/Sources/ApplicationConsumer/ApplicationConsumer.swift @@ -0,0 +1,36 @@ +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/Sources/TesseraHarness/Scenarios/CLI.swift b/Sources/TesseraHarness/Scenarios/CLI.swift deleted file mode 100644 index 6475718..0000000 --- a/Sources/TesseraHarness/Scenarios/CLI.swift +++ /dev/null @@ -1,151 +0,0 @@ -import Foundation - -// The command line's stdout is its product, so printing is the point here. -// swiftlint:disable no_print - -/// `headless run [--json] [--out DIR]` and `headless natural APP [--width N]`. -/// -/// Exit codes: 0 every scenario passed, 1 an expectation failed, 2 usage or parse error. -package enum CLI { - static let usage = """ - usage: - headless run [--json] [--out DIR] - headless natural [--width N] - headless help - apps: \(DemoRegistry.names.joined(separator: ", ")) - """ - - package static func main(arguments: [String]) -> Int32 { - guard let command = arguments.first else { - return usageError("missing command") - } - switch command { - case "run": - return run(Array(arguments.dropFirst())) - case "natural": - return natural(Array(arguments.dropFirst())) - case "help", "--help", "-h": - print(usage) - return 0 - default: - return usageError("unknown command `\(command)`; accepted: run, natural, help") - } - } - - private static func run(_ arguments: [String]) -> Int32 { - var files: [String] = [] - var json = false - var outDirectory: String? - var iterator = arguments.makeIterator() - while let argument = iterator.next() { - switch argument { - case "--json": - json = true - case "--out": - guard let directory = iterator.next() else { - return usageError("--out needs a directory") - } - outDirectory = directory - default: - if argument.hasPrefix("--") { - return usageError("unknown flag `\(argument)`; accepted: --json, --out") - } - files.append(argument) - } - } - guard !files.isEmpty else { - return usageError("run needs at least one scenario file") - } - - var exitCode: Int32 = 0 - for file in files { - let report: ScenarioReport - var artifacts: [ShotArtifact] = [] - do { - guard let data = FileManager.default.contents(atPath: file) else { - throw ScenarioParseError(file: file, line: 0, message: "cannot read file") - } - // swiftlint:disable:next optional_data_string_conversion - let source = String(decoding: data, as: UTF8.self) - let scenario = try ScenarioParser.parse(source, file: file) - (report, artifacts) = ScenarioRunner.execute(scenario, name: file) - if !report.passed { - exitCode = max(exitCode, 1) - } - } catch let error as ScenarioParseError { - report = ScenarioReport.parseFailure(error) - exitCode = 2 - } catch { - report = ScenarioReport.parseFailure( - ScenarioParseError(file: file, line: 0, message: "\(error)")) - exitCode = 2 - } - - print(json ? report.json() : report.human()) - if let outDirectory { - do { - try write(artifacts, for: file, into: outDirectory) - } catch { - FileHandle.standardError.write(Data("error: \(error)\n".utf8)) - exitCode = 2 - } - } - } - return exitCode - } - - private static func natural(_ arguments: [String]) -> Int32 { - var app: String? - var width = 40 - var iterator = arguments.makeIterator() - while let argument = iterator.next() { - if argument == "--width" { - guard let value = iterator.next(), let parsed = Int(value), parsed >= 1 else { - return usageError("--width needs a positive integer") - } - width = parsed - } else if app == nil { - app = argument - } else { - return usageError("unexpected argument `\(argument)`") - } - } - guard let app, let measured = DemoRegistry.naturalHeight(app: app, width: width) - else { - return usageError( - "natural needs a registered app; accepted: \(DemoRegistry.names.joined(separator: ", "))" - ) - } - print( - "\(app) width=\(width) probe=\(measured.probe.columns)x\(measured.probe.rows) " - + "root_rows=\(measured.rows) fills_proposal=\(measured.fillsProposal)") - return 0 - } - - /// Writes `//.txt` and `.tree.txt`, only when bytes differ. - private static func write( - _ artifacts: [ShotArtifact], for file: String, into directory: String - ) - throws - { - let stem = URL(fileURLWithPath: file).deletingPathExtension().lastPathComponent - let folder = URL(fileURLWithPath: directory).appendingPathComponent(stem) - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - for artifact in artifacts { - for (suffix, text) in [("txt", artifact.text), ("tree.txt", artifact.tree)] { - let url = folder.appendingPathComponent("\(artifact.name).\(suffix)") - let data = Data(text.utf8) - if FileManager.default.contents(atPath: url.path) != data { - try data.write(to: url) - } - } - } - } - - private static func usageError(_ message: String) -> Int32 { - FileHandle.standardError.write(Data("error: \(message)\n\(usage)\n".utf8)) - return 2 - } -} - -// swiftlint:enable no_print diff --git a/Sources/TesseraRuntime/Application.swift b/Sources/TesseraRuntime/Application.swift index 63ff38f..fd7d542 100644 --- a/Sources/TesseraRuntime/Application.swift +++ b/Sources/TesseraRuntime/Application.swift @@ -71,7 +71,11 @@ import TesseraTerminalInput /// When production code must stay free of the contract, a test or development target adopts it /// with the isolation the type already has: `extension Counter: Application {}` for a /// nonisolated type, and `extension Notes: @MainActor Application {}` for a main-actor one. -/// Writing the second without `@MainActor` is a compile error, not a runtime trap. +/// From another module, writing the second without `@MainActor` is a compile error, not a +/// runtime trap. In the module that declares the type, the compiler infers the isolated +/// conformance when the module uses default main-actor isolation or enables the +/// `InferIsolatedConformances` upcoming feature, so `extension Notes: Application {}` is +/// already isolated there; in plain Swift 6 mode the same line is a compile error. public protocol Application: AnyObject { /// The type of the root view. associatedtype Body: View 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 f6bab8e..be132ea 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -16,7 +16,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`) is green on every leg, Windows and the static Linux SDK included, and the merged 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` - [ ] 5 Scenario language, runner, the `tessera` CLI, and the CI corpus - [ ] 6 Projection, locators, invariants, cost, trace, and shots, with the first demos @@ -104,7 +104,7 @@ Isolation is pinned by two compile-fail fixtures beside `Tests/CompileFailFixtur 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. -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; 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, 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. +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 (Slice 3 checked the second one: a type from a default-main-actor module, extended in another module without `@MainActor`, is diagnosed with and without `InferIsolatedConformances` in the extension's module, so the fixture needs no flag handling), 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. ### 5 Scenario language, runner, the `tessera` CLI, and the CI corpus @@ -257,11 +257,11 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-23 Slice 2 seam shapes. `InMemoryTerminalDevice` registers an open device's byte and size-change streams inside the actor when `terminalDevice` is read, instead of the patch's unstructured attach `Task` with a replay of every delivered size: a resize delivered after the read is never lost to an attach race, and a late reader sees no history (plan 033 Step A.2's since-subscription semantics, without the race). The streams are single-reader, which `PlatformIO` satisfies (one `bytes()` and one `sizeChanges()` call). `TerminalSession.outputTap` tees only a draw's bytes, before its `io.write` (plan 033's "draw tap"); clipboard, image, probe, and cursor-restore writes stay untapped. `lastDrawn` sits beside the renderer's diff base rather than replacing it: `invalidateRenderer()` clears the base but not `lastDrawn`, because the terminal still shows that frame. `size` is `get async throws`, as `PlatformIO.size()` throws. `InMemoryTerminalSession.package init(device:)` from the patch stays, since an open device is only built through the package init. The `probeImageID` and `probeSleep` defaults are hoisted into `randomProbeImageID()` and `continuousClockSleep`, so the init and the `withApplicationTerminal` static share them. (this plan, Slice 2 run) - 2026-10-23 Slice 2 `draw` shape, implementing the runtime isolation entry above: `draw` and its helper `drawHoldingGate` are `nonisolated(nonsending)`, so the body runs in the caller's isolation; the session-isolated steps are the gate (`acquireDrawGate`, `releaseDrawGate`), `beginFrame` (open check, size, and the rendering policy as one `FramePolicy` value, still the commit point), and `commitFrame` (encode, tee `outputTap`, write, flush, then set the diff base and `lastDrawn` only after a successful flush). `lastDrawn` and `outputTap` keep the semantics of the entry above. Only the finished `Buffer`, the cursor position, and the policy cross the boundary; `Frame` never does. (this plan, Slice 2 run) - 2026-10-24 Slice 3 copied the spike instead of moving it (owner to weigh). `spikes/README.md` and plan 033 Step A.3 call the migration a copy, the spike READMEs are frozen evidence of what each spike proved, and removing `spikes/HeadlessRunner` would delete its `Package.resolved`, which no slice changes. `Sources/TesseraHarness` and `Tests/TesseraHarnessTests` are the maintained copy, and both spike READMEs say so. If the owner wants the plan's "move" literally, removing the spike's `Sources`, `Tests`, `Scenarios`, and manifest is a mechanical follow-up. (this plan, Slice 3 run) -- 2026-10-24 What the harness took from the spike, and its access. The driver, the scenario parser, runner, report, key names, locators, the registry with natural-height probing, and the two spike fixtures (`StackApp`, `WorkspaceApp`) moved with the spike's 18 tests (changed only in their imports and the corpus path) and its `Scenarios/*.scn`, byte-identical. Every `public` became `package`, so the target declares no public name; `DemoApp` and `DemoRegistry`, which stand in for `Application` and `ApplicationRegistry`, stay `package` until Slice 4 replaces them, and the fixtures go with them or become registrations of `Application` types. The `headless` executable's `main.swift` did not move, since a root-package library hosts no executable and Slice 4's `TesseraDev` is the executable; `CLI.main(arguments:)` moved as `package` code that nothing calls yet, for Slice 4 to replace with `HarnessCLI.main(registry:)`. The corpus sits at `Tests/TesseraHarnessTests/Scenarios`, excluded from the test target. (this plan, Slice 3 run) +- 2026-10-24 What the harness took from the spike, and its access. The driver, the scenario parser, runner, report, key names, locators, the registry with natural-height probing, and the two spike fixtures (`StackApp`, `WorkspaceApp`) moved with the spike's 18 tests (changed only in their imports and the corpus path) and its `Scenarios/*.scn`, byte-identical. Every `public` became `package`, so the target declares no public name; `DemoApp` and `DemoRegistry`, which stand in for `Application` and `ApplicationRegistry`, stay `package` until Slice 4 replaces them, and the fixtures go with them or become registrations of `Application` types. The `headless` executable's `main.swift` and the spike's `CLI.swift` did not move: a root-package library hosts no executable, and the critic review found a copied `CLI` had no caller and no test, so Slice 4 writes `HarnessCLI.main(registry:)` fresh in `TesseraDev` and the spike keeps the original. The corpus sits at `Tests/TesseraHarnessTests/Scenarios`, excluded from the test target. (this plan, Slice 3 run) - 2026-10-24 `TesseraHarness` depends on the narrow modules it imports, not on the `Tessera` umbrella: `TesseraCore`, `TesseraLayout`, and `TesseraWidgets` (for the two fixtures), `TesseraRuntime`, `TesseraTerminalCore`, `TesseraTerminalInput`, and `TesseraTerminalSnapshotSupport`. That list is also its boundary allowlist row, following the layout spec's rule that internal code imports the narrower target it needs; Slice 4 widens the row for `TesseraTerminal` and the in-memory device. `TesseraRuntime` is a target that `Tessera` re-exports, not a product, as the layout spec's product list has it, and its DocC catalog has no icon or card art. (this plan, Slice 3 run) -- 2026-10-24 No per-OS `sources:` shims (owner to weigh). The slice text asks for platform code in per-OS `sources:` shims, but the moved code uses only Foundation APIs (`FileManager`, `FileHandle`, `URL`, `JSONEncoder`) that exist on macOS, Linux, and Windows, so no `#if` and no divergent file was needed. The first platform seam arrives with Slice 4's live runner, which needs the terminal device. (this plan, Slice 3 run) +- 2026-10-24 No per-OS `sources:` shims (owner to weigh). The slice text asks for platform code in per-OS `sources:` shims, but the moved code uses only Foundation APIs (`JSONEncoder`, `String.trimmingCharacters`) that exist on macOS, Linux, and Windows, so no `#if` and no divergent file was needed. The first platform seam arrives with Slice 4's live runner, which needs the terminal device; the file reads and writes the spike's `CLI` did are part of what Slice 4's `HarnessCLI` writes fresh. (this plan, Slice 3 run) - 2026-10-24 `.gitattributes` gains `*.scn text eol=lf` (owner to weigh). The parser splits on `"\n"`, and `"\r\n"` is one Swift `Character`, so a CRLF checkout (Git for Windows' default `core.autocrlf`) reads a whole scenario as one line and fails the moved corpus on the Windows leg. Decision 7 planned `.gitattributes` for Slice 5's corpus; this is its first entry, earlier than planned, and Slice 5's golden patterns extend it. (this plan, Slice 3 run) -- 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; a paired `no_print` disable around `CLI.swift`, whose stdout is its product; and targeted disables for `discouraged_optional_collection` (`ScenarioFailure.screen`, where nil is omitted from the JSON) and `optional_data_string_conversion` (two lossy UTF-8 decodes). (this plan, Slice 3 run) +- 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. @@ -283,6 +283,9 @@ Rules for an agent running slices of this plan without the owner. - `Tessera` does not re-export `Observation`, so the `@Observable` adoption-shape test imports it beside `Tessera`: the plan's "imports only `Tessera`" holds for every Tessera module, and an app author's `@Observable` model needs `import Observation` whatever else it adopts. - A `"\r\n"` is one `Character`, so `"a\r\nb".split(separator: "\n")` yields one piece. Slice 5's parser should split on `\n` and tolerate `\r`, or normalise line endings, rather than rely on `.gitattributes` alone, because an app author's scenario files may be checked out with CRLF. - SwiftLint in new files: a `disable:next` comment between a doc comment and its declaration orphans the doc comment (`orphaned_doc_comment`), so the disable goes on the declaration's own line (`disable:this`); a bare `disable` needs a later `enable` (`blanket_disable_command`). +- 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. Scratch typechecks on Xcode 26.6 settle it: inside the module that declares the type, plain Swift 6 mode diagnoses it ("crosses into main actor-isolated code and can cause data races"), while `-default-isolation MainActor` and the `InferIsolatedConformances` upcoming feature (which this package's shared `swiftSettings` and the Examples manifest enable) infer the isolated conformance and compile it; from another module, which is the test-or-dev-target case the sentence was written for, it is diagnosed in both modes, and `extension Journal: @MainActor Application {}` compiles. The docs and the spec now say exactly that. +- 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. ## Outcome diff --git a/docs/spec/phase-5-runtime-and-polish.md b/docs/spec/phase-5-runtime-and-polish.md index e9213aa..945db8a 100644 --- a/docs/spec/phase-5-runtime-and-polish.md +++ b/docs/spec/phase-5-runtime-and-polish.md @@ -111,7 +111,7 @@ nonisolated final class Tool: Application { /* nonisolated members */ } } ``` -When production code must stay free of the contract, a test or dev target adopts it with the isolation the type has: `extension Counter: Application {}` for a nonisolated type, `extension Notes: @MainActor Application {}` for a main-actor one. Writing the second without `@MainActor` is a compile error ("conformance of 'Notes' to protocol 'Application' crosses into main actor-isolated code and can cause data races"), not a trap. +When production code must stay free of the contract, a test or dev target adopts it with the isolation the type has: `extension Counter: Application {}` for a nonisolated type, `extension Notes: @MainActor Application {}` for a main-actor one. From another module, writing the second without `@MainActor` is a compile error ("conformance of 'Notes' to protocol 'Application' crosses into main actor-isolated code and can cause data races"), not a trap. In the module that declares the type, the compiler infers the isolated conformance when the module uses default main-actor isolation or enables the `InferIsolatedConformances` upcoming feature, so `extension Notes: Application {}` is already isolated there; in plain Swift 6 mode the same line is the same error. Either way no path is a runtime trap. ### The runtime diff --git a/spikes/HeadlessRunner/README.md b/spikes/HeadlessRunner/README.md index b789171..5926bf3 100644 --- a/spikes/HeadlessRunner/README.md +++ b/spikes/HeadlessRunner/README.md @@ -2,7 +2,7 @@ Plan 033, Spike 1. A standalone package that depends on Tessera by path (`../..`) and uses public API only. -The code has since been copied into the root package's `TesseraHarness` target and `TesseraHarnessTests` (plan 036 Slice 3), where it is maintained. This package stays as frozen evidence. +The library code has since been copied into the root package's `TesseraHarness` target and `TesseraHarnessTests` (plan 036 Slice 3), where it is maintained; the `headless` command line was not copied, because plan 036 Slice 4 writes the `tessera` CLI fresh. This package stays as frozen evidence. ## Hypothesis diff --git a/spikes/README.md b/spikes/README.md index 07e58ad..b75b0eb 100644 --- a/spikes/README.md +++ b/spikes/README.md @@ -19,7 +19,7 @@ GhosttyDistribution is not a path-dependent package: it is the saved sources of The spikes are frozen evidence for plan 033: they are built by hand and stay out of CI (owner decision, 2026-10-21), so nothing guards them against drift in the root package. They were last built and tested on 2026-10-22 against the root package at that date, on both toolchain lanes (Xcode 26.6 and Xcode 27.1). -HeadlessRunner's code has been copied into the root package (`Sources/TesseraHarness` and `Tests/TesseraHarnessTests`, plan 036 Slice 3), where it is maintained from now on. The copy here stays as the record of what the spike proved and is no longer updated. +HeadlessRunner's library code has been copied into the root package (`Sources/TesseraHarness` and `Tests/TesseraHarnessTests`, plan 036 Slice 3), where it is maintained from now on; its command line (`headless`, `CLI.swift`) was not copied, because plan 036 Slice 4 writes the `tessera` CLI fresh. The copy here stays as the record of what the spike proved and is no longer updated. ## Running From 096cec927ddb9b414ec2ea86186599227b284aa7 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 00:38:38 -0400 Subject: [PATCH 10/37] docs(harness): state the isolation rule by compiler mode, not by module A scratch two-module SwiftPM package on Swift 6.3.3 and 6.4 settled when a bare extension conformance on a main-actor type compiles: it is an error in plain Swift 6 mode, builds with default main-actor isolation in the extension's module, and builds under InferIsolatedConformances (on 6.3.3 only for a type declared @MainActor, not one isolated through its module's default isolation). Rewrite the Application docs, the spec paragraph, and the plan's Discoveries and Slice 4 Verify to say so, run Slice 4's second fixture in plain Swift 6 mode, and describe ShotArtifact without the removed CLI. --- Sources/TesseraHarness/Scenarios/Report.swift | 2 +- Sources/TesseraRuntime/Application.swift | 12 +++++++----- .../phase-4-view-layer-and-developer-tooling.md | 6 +++--- docs/spec/phase-5-runtime-and-polish.md | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Sources/TesseraHarness/Scenarios/Report.swift b/Sources/TesseraHarness/Scenarios/Report.swift index a2772a4..6b793ee 100644 --- a/Sources/TesseraHarness/Scenarios/Report.swift +++ b/Sources/TesseraHarness/Scenarios/Report.swift @@ -21,7 +21,7 @@ package struct ScenarioObservation: Codable, Equatable, Sendable { package var lines: [String] } -/// A file a `shot` statement produces when the CLI runs with `--out`. +/// The text and the node tree a `shot` statement captures, for a runner to write as files. struct ShotArtifact: Equatable, Sendable { var name: String var text: String diff --git a/Sources/TesseraRuntime/Application.swift b/Sources/TesseraRuntime/Application.swift index fd7d542..87f4a04 100644 --- a/Sources/TesseraRuntime/Application.swift +++ b/Sources/TesseraRuntime/Application.swift @@ -71,11 +71,13 @@ import TesseraTerminalInput /// When production code must stay free of the contract, a test or development target adopts it /// with the isolation the type already has: `extension Counter: Application {}` for a /// nonisolated type, and `extension Notes: @MainActor Application {}` for a main-actor one. -/// From another module, writing the second without `@MainActor` is a compile error, not a -/// runtime trap. In the module that declares the type, the compiler infers the isolated -/// conformance when the module uses default main-actor isolation or enables the -/// `InferIsolatedConformances` upcoming feature, so `extension Notes: Application {}` is -/// already isolated there; in plain Swift 6 mode the same line is a compile error. +/// Writing `@MainActor` on the conformance is accepted in every mode. Leaving it off depends +/// on the compiler mode of the module that writes the extension: in plain Swift 6 mode it is a +/// compile error, not a runtime trap, and with the `InferIsolatedConformances` upcoming feature +/// or default main-actor isolation the compiler infers the isolated conformance instead. +/// Swift 6.3 infers it under `InferIsolatedConformances` alone only for a type declared +/// `@MainActor`, not for one that is main-actor isolated through its own module's default +/// isolation; Swift 6.4 infers both. public protocol Application: AnyObject { /// The type of the root view. associatedtype Body: View 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 be132ea..0f34b8e 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -100,11 +100,11 @@ Everything else is `package`. `TesseraRuntime` gains the canonical step, a nonis `Examples/Sources/TesseraDev`, the target of the `tessera` executable product in the Examples package, is created here as `HarnessCLI.main(registry:)` (public, `async -> Never`) with `apps` (list the registry) and `launch NAME` (run one app live in this terminal) and `help COMMAND`; Slice 5 adds the rest. `mise-tasks/dev/apps` and `mise-tasks/dev/launch` mirror them (each with `#USAGE`). Check that the `tessera` executable and the `Tessera` library do not collide on case-insensitive file systems (macOS, Windows). -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 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`. +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. -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 (Slice 3 checked the second one: a type from a default-main-actor module, extended in another module without `@MainActor`, is diagnosed with and without `InferIsolatedConformances` in the extension's module, so the fixture needs no flag handling), 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. +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. ### 5 Scenario language, runner, the `tessera` CLI, and the CI corpus @@ -283,7 +283,7 @@ Rules for an agent running slices of this plan without the owner. - `Tessera` does not re-export `Observation`, so the `@Observable` adoption-shape test imports it beside `Tessera`: the plan's "imports only `Tessera`" holds for every Tessera module, and an app author's `@Observable` model needs `import Observation` whatever else it adopts. - A `"\r\n"` is one `Character`, so `"a\r\nb".split(separator: "\n")` yields one piece. Slice 5's parser should split on `\n` and tolerate `\r`, or normalise line endings, rather than rely on `.gitattributes` alone, because an app author's scenario files may be checked out with CRLF. - SwiftLint in new files: a `disable:next` comment between a doc comment and its declaration orphans the doc comment (`orphaned_doc_comment`), so the disable goes on the declaration's own line (`disable:this`); a bare `disable` needs a later `enable` (`blanket_disable_command`). -- 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. Scratch typechecks on Xcode 26.6 settle it: inside the module that declares the type, plain Swift 6 mode diagnoses it ("crosses into main actor-isolated code and can cause data races"), while `-default-isolation MainActor` and the `InferIsolatedConformances` upcoming feature (which this package's shared `swiftSettings` and the Examples manifest enable) infer the isolated conformance and compile it; from another module, which is the test-or-dev-target case the sentence was written for, it is diagnosed in both modes, and `extension Journal: @MainActor Application {}` compiles. The docs and the spec now say exactly that. +- 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. - 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. diff --git a/docs/spec/phase-5-runtime-and-polish.md b/docs/spec/phase-5-runtime-and-polish.md index 945db8a..f773591 100644 --- a/docs/spec/phase-5-runtime-and-polish.md +++ b/docs/spec/phase-5-runtime-and-polish.md @@ -111,7 +111,7 @@ nonisolated final class Tool: Application { /* nonisolated members */ } } ``` -When production code must stay free of the contract, a test or dev target adopts it with the isolation the type has: `extension Counter: Application {}` for a nonisolated type, `extension Notes: @MainActor Application {}` for a main-actor one. From another module, writing the second without `@MainActor` is a compile error ("conformance of 'Notes' to protocol 'Application' crosses into main actor-isolated code and can cause data races"), not a trap. In the module that declares the type, the compiler infers the isolated conformance when the module uses default main-actor isolation or enables the `InferIsolatedConformances` upcoming feature, so `extension Notes: Application {}` is already isolated there; in plain Swift 6 mode the same line is the same error. Either way no path is a runtime trap. +When production code must stay free of the contract, a test or dev target adopts it with the isolation the type has: `extension Counter: Application {}` for a nonisolated type, `extension Notes: @MainActor Application {}` for a main-actor one. Writing `@MainActor` on the conformance is accepted in every mode. Leaving it off depends on the compiler mode of the module that writes the extension, not on which module declares the type. In plain Swift 6 mode it is a compile error ("conformance of 'Notes' to protocol 'Application' crosses into main actor-isolated code and can cause data races"), not a trap. With the `InferIsolatedConformances` upcoming feature, or with default main-actor isolation in the extension's module, the compiler infers the isolated conformance instead and the line compiles. One corner differs by toolchain: under `InferIsolatedConformances` alone, Swift 6.3 infers it for a type declared `@MainActor` but not for a type that is main-actor isolated only through its own module's default isolation, while Swift 6.4 infers both. Either way no path is a runtime trap. ### The runtime From 338bd6f209a33f898a6e3f26628e687848f4c6de Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 01:29:48 -0400 Subject: [PATCH 11/37] feat(harness): add the application registry, the main-actor driver, and tessera launch ApplicationRegistry and Viewport are the first public names in TesseraHarness. The registry selects an app by name through a main-actor factory opened once per run, and a duplicate name makes every read of it fail. `mobile` is 40 columns at the app's natural height, measured with a package nil-height proposal on ViewGraph and two tall probe heights; an app whose root grows with the proposal fails `size mobile` naming `mobileScreen`. TesseraRuntime gains the package Step.run(_:graph:event:isolation:), and the harness gains the @MainActor Driver over it, shared by a headless host (a session over a headless device into a Ghostty virtual terminal), a live host, and a per-test actor host. HarnessCLI.main(registry:) runs apps, launch NAME, and help. DemoSupport adds the Demo protocol, isQuitKey, and a counter demo; Examples/Sources/TesseraDev builds it as the tessera executable, with mise run dev:apps and dev:launch. Two compile-fail fixtures with positive controls pin the Application isolation rules, run by quality:architecture. ApplicationConsumer is deleted, since DemoSupport imports Tessera from outside the root package. The tessera-devtools skill documents what is usable. --- .agents/skills/tessera-devtools/SKILL.md | 59 ++++ AGENTS.md | 2 +- CHANGELOG.md | 6 +- CONTRIBUTING.md | 2 + Examples/Package.swift | 46 ++- .../ApplicationConsumer.swift | 36 --- Examples/Sources/DemoSupport/Counter.swift | 63 ++++ Examples/Sources/DemoSupport/Demo.swift | 50 ++++ Examples/Sources/DemoSupport/Demos.swift | 27 ++ Examples/Sources/DemoSupport/QuitKey.swift | 11 + Examples/Sources/TesseraDev/main.swift | 6 + .../DemoSupportTests/CounterDemoTests.swift | 80 +++++ .../Tests/DemoSupportTests/QuitKeyTests.swift | 34 +++ Package.swift | 12 +- README.md | 2 +- Sources/TesseraCore/Runtime/ViewGraph.swift | 21 ++ Sources/TesseraHarness/ActorHost.swift | 52 ++++ .../TesseraHarness/ApplicationRegistry.swift | 136 +++++++++ Sources/TesseraHarness/Driver.swift | 109 +++---- Sources/TesseraHarness/HarnessCLI.swift | 274 ++++++++++++++++++ Sources/TesseraHarness/HeadlessHost.swift | 130 +++++++++ .../HeadlessTerminalDevice.swift | 33 +++ Sources/TesseraHarness/LiveHost.swift | 39 +++ Sources/TesseraHarness/NaturalHeight.swift | 83 ++++++ Sources/TesseraHarness/Registry.swift | 84 ------ Sources/TesseraHarness/Scenarios/Runner.swift | 101 +++++-- .../TesseraHarness/Scenarios/Scenario.swift | 31 +- Sources/TesseraHarness/StandardOutput.swift | 18 ++ Sources/TesseraHarness/Viewport.swift | 45 +++ Sources/TesseraRuntime/Step.swift | 54 ++++ .../ActorHostMainActorApp.swift | 32 ++ .../AdoptWithoutIsolation.swift | 13 + .../DefaultIsolationJournal.swift | 20 ++ .../RootMeasurementTests.swift | 67 +++++ Tests/TesseraHarnessTests/DriverTests.swift | 204 +++++++++++++ .../TesseraHarnessTests/Fixtures.swift | 143 ++++++++- .../TesseraHarnessTests/HarnessCLITests.swift | 162 +++++++++++ Tests/TesseraHarnessTests/RegistryTests.swift | 109 +++++++ Tests/TesseraHarnessTests/ScenarioTests.swift | 189 +++++++----- Tests/TesseraRuntimeTests/StepTests.swift | 120 ++++++++ docs/architecture/developer-tooling.md | 16 +- docs/spec/module-and-file-layout.md | 16 +- mise-tasks/dev/apps | 14 + mise-tasks/dev/launch | 6 + mise-tasks/quality/architecture | 4 +- scripts/check-isolation-fixtures.sh | 120 ++++++++ scripts/check-package-boundaries.py | 4 +- scripts/test_check_package_boundaries.py | 8 +- 48 files changed, 2564 insertions(+), 329 deletions(-) create mode 100644 .agents/skills/tessera-devtools/SKILL.md delete mode 100644 Examples/Sources/ApplicationConsumer/ApplicationConsumer.swift create mode 100644 Examples/Sources/DemoSupport/Counter.swift create mode 100644 Examples/Sources/DemoSupport/Demo.swift create mode 100644 Examples/Sources/DemoSupport/Demos.swift create mode 100644 Examples/Sources/DemoSupport/QuitKey.swift create mode 100644 Examples/Sources/TesseraDev/main.swift create mode 100644 Examples/Tests/DemoSupportTests/CounterDemoTests.swift create mode 100644 Examples/Tests/DemoSupportTests/QuitKeyTests.swift create mode 100644 Sources/TesseraHarness/ActorHost.swift create mode 100644 Sources/TesseraHarness/ApplicationRegistry.swift create mode 100644 Sources/TesseraHarness/HarnessCLI.swift create mode 100644 Sources/TesseraHarness/HeadlessHost.swift create mode 100644 Sources/TesseraHarness/HeadlessTerminalDevice.swift create mode 100644 Sources/TesseraHarness/LiveHost.swift create mode 100644 Sources/TesseraHarness/NaturalHeight.swift delete mode 100644 Sources/TesseraHarness/Registry.swift create mode 100644 Sources/TesseraHarness/StandardOutput.swift create mode 100644 Sources/TesseraHarness/Viewport.swift create mode 100644 Sources/TesseraRuntime/Step.swift create mode 100644 Tests/CompileFailFixtures/ActorHostMainActorApp.swift create mode 100644 Tests/CompileFailFixtures/AdoptWithoutIsolation.swift create mode 100644 Tests/CompileFailFixtures/DefaultIsolationJournal.swift create mode 100644 Tests/TesseraCoreTests/RootMeasurementTests.swift create mode 100644 Tests/TesseraHarnessTests/DriverTests.swift rename Sources/TesseraHarness/Demos.swift => Tests/TesseraHarnessTests/Fixtures.swift (51%) create mode 100644 Tests/TesseraHarnessTests/HarnessCLITests.swift create mode 100644 Tests/TesseraHarnessTests/RegistryTests.swift create mode 100644 Tests/TesseraRuntimeTests/StepTests.swift create mode 100755 mise-tasks/dev/apps create mode 100755 mise-tasks/dev/launch create mode 100755 scripts/check-isolation-fixtures.sh diff --git a/.agents/skills/tessera-devtools/SKILL.md b/.agents/skills/tessera-devtools/SKILL.md new file mode 100644 index 0000000..a1c7558 --- /dev/null +++ b/.agents/skills/tessera-devtools/SKILL.md @@ -0,0 +1,59 @@ +--- +name: tessera-devtools +description: Drive Tessera's own demos and check them. Use when listing, launching, or snapshot-testing a demo app, adding a demo, running the `tessera` CLI or `mise run dev:*`, or reading what the harness reports about an app. +version: 0.1.0 +--- + +# Tessera developer tools + +The tools run Tessera apps live in a terminal and headlessly in tests, through one driver, so what a test renders is what a terminal shows. [Developer tooling](../../../docs/architecture/developer-tooling.md) is the design and says which tools exist yet. This skill is how to use the ones that do, and it grows with them. Use only the commands below; a command that is not listed does not exist yet. + +## What exists + +| Command | Does | +| --- | --- | +| `mise run dev:apps` | List the registered apps (`tessera apps`): name, minimum size, viewports. | +| `mise run dev:launch NAME` | Run one app in this terminal (`tessera launch NAME`) until the app quits. | +| `mise run dev:apps --json` or `--pretty` | Force the JSON object or the text table. | + +`tessera` is the executable of `Examples/Sources/TesseraDev`; `mise run dev:*` builds and runs it (`swift run --package-path Examples tessera …`). Standard output is one JSON object when it is not a terminal, and a text table when it is. Exit codes: 0 success, 2 a usage error (an unknown command or app name, with the accepted names listed), 3 a harness error (a name registered twice, or `launch` without a terminal). `tessera help [COMMAND]` prints real text. + +## Run a demo + +`mise run dev:launch counter` opens the counter in your terminal. The harness has no quit key: each demo quits on `q`, matched by `isQuitKey` in `Examples/Sources/DemoSupport`. A run needs a real terminal on standard input and output. An agent without one runs it under a pty: + +```sh +expect -c 'set timeout 20; spawn sh -c "stty rows 24 cols 80; exec mise run dev:launch counter"; expect "Count"; send "+"; send "q"; expect eof' +``` + +Read the screen from the bytes the run wrote (cursor addressing, then text); 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; app authors do not get this API. The viewports are `desktop` (80x24), `mobile` (40 columns at the demo's natural height), `mobileScreen` (40x16), and `custom`. A demo whose content grows to any height it is offered has no natural height, so `mobile` fails and names `mobileScreen`. + +## 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.md b/AGENTS.md index a8d7e60..ce6a2c4 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`; running, launching, and snapshot-testing Tessera's demos follows `tessera-devtools`. 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 36795a1..9ffb615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,11 @@ 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 refines nothing but `AnyObject`, 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 yet. +- 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-devtools` agent skill for running, launching, and snapshot-testing demos, with a pointer from `AGENTS.md`. +- 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 c5b7026..f09b4f1 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.swift b/Examples/Package.swift index 14b222f..77f6a44 100644 --- a/Examples/Package.swift +++ b/Examples/Package.swift @@ -30,6 +30,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 +43,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 +53,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 +65,7 @@ let AllTesseraExampleTargetNames: Set = [ "Phase3ProtocolsDemo", "Phase3ProtocolsDemoSupport", "RendererDemo", + "TesseraDev", ] // MARK: - 🎯 Products & Targets @@ -77,16 +84,25 @@ 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: [ + DemoSupport, + Tessera, + TesseraHarness, + ] + ), +]) // MARK: ExampleSupport @@ -190,6 +206,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 47e7195..0000000 --- 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 0000000..a6ec01b --- /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 0000000..1881900 --- /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 0000000..dac4ed2 --- /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 0000000..d98d649 --- /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 0000000..defda1d --- /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 0000000..9938066 --- /dev/null +++ b/Examples/Tests/DemoSupportTests/CounterDemoTests.swift @@ -0,0 +1,80 @@ +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)) + #expect(host.screenRows[0] == "Counter") + #expect(host.screenRows[2] == "Count" + String(repeating: " ", count: 74) + "0") + #expect(host.screenRows[4] == "+ 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)) + #expect(host.screenRows.count == 5) + #expect(host.screenRows[2] == "Count" + String(repeating: " ", count: 34) + "0") + } + } + + @Test + func `plus and minus step the count and zero resets it`() async throws { + try await Demos.registry().withHeadlessHost("counter", at: .mobile) { host in + try await host.send(plus) + try await host.send(plus) + #expect(host.screenRows[2].hasSuffix("2")) + try await host.send(.key(Key(code: .character("-")))) + #expect(host.screenRows[2].hasSuffix("1")) + try await host.send(.key(Key(code: .character("0")))) + #expect(host.screenRows[2].hasSuffix("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() + + #expect(names == ["counter", "counter-high"]) + } +} diff --git a/Examples/Tests/DemoSupportTests/QuitKeyTests.swift b/Examples/Tests/DemoSupportTests/QuitKeyTests.swift new file mode 100644 index 0000000..fedf108 --- /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.swift b/Package.swift index b27de06..5b27536 100644 --- a/Package.swift +++ b/Package.swift @@ -577,19 +577,27 @@ package.targets.append(contentsOf: [ name: "TesseraHarness", dependencies: [ TesseraCore, - TesseraLayout, TesseraRuntime, + TesseraTerminal, TesseraTerminalCore, TesseraTerminalInput, + TesseraTerminalIO, TesseraTerminalSnapshotSupport, - TesseraWidgets, ] ), .testTarget( name: "TesseraHarnessTests", dependencies: [ + TesseraCore, TesseraHarness, + TesseraLayout, + TesseraRuntime, + TesseraTerminal, + TesseraTerminalCore, TesseraTerminalInput, + TesseraTerminalSnapshotSupport, + TesseraTerminalTestSupport, + TesseraWidgets, ], exclude: ["Scenarios"] ), diff --git a/README.md b/README.md index f016727..354b4b9 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 6233a59..2127ba2 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 0000000..0eaa076 --- /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 0000000..fbcfdac --- /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: Error, Equatable, Sendable, CustomStringConvertible { + /// 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 b29d34d..0fb1fec 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` is a stub at HEAD (investigation 039), so input never reaches -/// views. The app therefore owns its model: ``apply(_:)`` is the seam where a real -/// runner would deliver the event after `graph.dispatch` returned `.ignored`. -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.swift b/Sources/TesseraHarness/HarnessCLI.swift new file mode 100644 index 0000000..ed0a1b8 --- /dev/null +++ b/Sources/TesseraHarness/HarnessCLI.swift @@ -0,0 +1,274 @@ +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. +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, + outputIsTerminal: StandardOutput.isTerminal, + configuration: .default + ) + 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 { + private struct AppsListing: Encodable { + struct App: Encodable { + var minimum: String? + var name: String + var viewports: [String] + } + + var apps: [App] + var command = "apps" + } + + private struct Failure: Encodable { + var command: String? + var exitCode: Int32 + var message: String + } + + static let commands = ["apps", "launch", "help"] + + private static let overview = """ + usage: COMMAND [ARGUMENT] + + commands: + apps list the registered applications + launch NAME run one application in this terminal + help [COMMAND] describe a command + + 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. + """ + + private static let helpText: [String: String] = [ + "apps": """ + usage: apps [--json | --pretty] + + 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. A name registered twice fails the + command with exit 3. + """, + "launch": """ + usage: launch NAME + + Runs the named application in this terminal 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 output or input is not a terminal. + """, + "help": """ + usage: help [COMMAND] + + Prints the commands, or one command's usage. + """, + ] + + /// 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 `outputIsTerminal` + /// is `true`; this keeps a test or a pipe from ever opening a live session. + @MainActor + static func run( + arguments: [String], + registry: ApplicationRegistry, + outputIsTerminal: Bool, + configuration: TerminalApplicationConfiguration + ) async -> CommandOutput { + var arguments = arguments + let asJSON: Bool + switch (arguments.contains("--json"), arguments.contains("--pretty")) { + case (true, true): + return usage( + "`--json` and `--pretty` contradict each other", command: nil, json: false) + case (true, false): + asJSON = true + case (false, true): + asJSON = false + case (false, false): + asJSON = !outputIsTerminal + } + arguments.removeAll { $0 == "--json" || $0 == "--pretty" } + + guard let command = arguments.first else { + return usage( + "a command is required; accepted: \(commands.joined(separator: ", "))", + command: nil, json: asJSON) + } + let rest = Array(arguments.dropFirst()) + switch command { + case "apps": + return apps(rest, registry: registry, json: asJSON) + case "launch": + return await launch( + rest, registry: registry, json: asJSON, outputIsTerminal: outputIsTerminal, + configuration: configuration) + case "help": + return help(rest, json: asJSON) + default: + return usage( + "unknown command `\(command)`; accepted: \(commands.joined(separator: ", "))", + command: nil, json: asJSON) + } + } + + // MARK: Commands + + private static func apps( + _ arguments: [String], registry: ApplicationRegistry, json: Bool + ) -> CommandOutput { + if let extra = arguments.first { + return usage( + "`apps` takes no argument, got `\(extra)`", command: "apps", json: json) + } + let entries: [ApplicationRegistry.Entry] + do { + entries = try registry.checkedEntries() + } catch { + return failure(error.description, exitCode: 3, command: "apps", json: json) + } + + if json { + let listing = AppsListing( + apps: entries.map { entry in + AppsListing.App( + minimum: entry.minimum.map { "\($0.columns)x\($0.rows)" }, + name: entry.name, + viewports: entry.viewports.map(\.name)) + }) + return CommandOutput(exitCode: 0, stdout: 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") + } + + private static func launch( + _ arguments: [String], + registry: ApplicationRegistry, + json: Bool, + outputIsTerminal: Bool, + configuration: TerminalApplicationConfiguration + ) async -> CommandOutput { + guard arguments.count == 1, let name = arguments.first, !name.hasPrefix("-") else { + return usage("usage: launch NAME", command: "launch", json: json) + } + let entry: ApplicationRegistry.Entry + do { + entry = try registry.entry(named: name) + } catch { + let exitCode: Int32 = + switch error { + case .duplicate: 3 + case .unknown: 2 + } + return failure(error.description, exitCode: exitCode, command: "launch", json: json) + } + guard outputIsTerminal else { + return failure( + "`launch` runs an application in this terminal, and standard output is not one", + exitCode: 3, command: "launch", json: json) + } + do { + try await entry.live(configuration) + } catch { + return failure( + "`launch \(name)` could not run in this terminal: \(error)", + exitCode: 3, command: "launch", json: json) + } + return CommandOutput(exitCode: 0) + } + + private static func help(_ arguments: [String], json: Bool) -> CommandOutput { + guard let topic = arguments.first else { + return CommandOutput(exitCode: 0, stdout: overview + "\n") + } + guard arguments.count == 1 else { + return usage("usage: help [COMMAND]", command: "help", json: json) + } + guard let text = helpText[topic] else { + return usage( + "unknown command `\(topic)`; accepted: \(commands.joined(separator: ", "))", + command: "help", json: json) + } + return CommandOutput(exitCode: 0, stdout: text + "\n") + } + + // MARK: Output + + private 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) + } + + private static func usage(_ message: String, command: String?, json: Bool) + -> CommandOutput + { + failure(message, exitCode: 2, command: command, json: json) + } + + private 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") + } +} diff --git a/Sources/TesseraHarness/HeadlessHost.swift b/Sources/TesseraHarness/HeadlessHost.swift new file mode 100644 index 0000000..d34048b --- /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 0000000..75da9d8 --- /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 0000000..bf536e3 --- /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 0000000..66993d7 --- /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 afbb9b0..0000000 --- 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 9b036ab..09a0c45 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 d8dac6f..d02a7c1 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: 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/StandardOutput.swift b/Sources/TesseraHarness/StandardOutput.swift new file mode 100644 index 0000000..285b684 --- /dev/null +++ b/Sources/TesseraHarness/StandardOutput.swift @@ -0,0 +1,18 @@ +import Foundation + +#if os(Windows) + import WinSDK +#endif + +/// The one platform question the command line asks: is standard output a terminal. +enum StandardOutput { + /// Whether standard output is attached to a terminal or console. + static var isTerminal: 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 0000000..8a94e01 --- /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/Step.swift b/Sources/TesseraRuntime/Step.swift new file mode 100644 index 0000000..07fbef2 --- /dev/null +++ b/Sources/TesseraRuntime/Step.swift @@ -0,0 +1,54 @@ +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 nonisolated caller cannot compile the call. +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/Tests/CompileFailFixtures/ActorHostMainActorApp.swift b/Tests/CompileFailFixtures/ActorHostMainActorApp.swift new file mode 100644 index 0000000..3830589 --- /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 0000000..c87e68b --- /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 0000000..3c11a57 --- /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 0000000..688fd5b --- /dev/null +++ b/Tests/TesseraCoreTests/RootMeasurementTests.swift @@ -0,0 +1,67 @@ +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) + + #expect( + graph.measuredRootSize(proposing: ProposedSize(width: 10, height: nil)) + == TerminalSize(columns: 10, rows: 3)) + #expect( + graph.measuredRootSize(proposing: ProposedSize(width: 10, height: 500)) + == TerminalSize(columns: 10, rows: 500)) + #expect( + graph.measuredRootSize(proposing: ProposedSize(width: 10, height: 1_000)) + == 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 0000000..c72fa93 --- /dev/null +++ b/Tests/TesseraHarnessTests/DriverTests.swift @@ -0,0 +1,204 @@ +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)) + #expect( + output.debugDescription + == #""\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""# + ) + #expect(Array(await device.events.suffix(2)) == [.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)) + #expect(rows.first == "Workspace 50x12") + #expect(rows.count == 12) + } + + @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 + } + + #expect(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 45d50ae..4bb6f70 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` is a stub. -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 0000000..7e49292 --- /dev/null +++ b/Tests/TesseraHarnessTests/HarnessCLITests.swift @@ -0,0 +1,162 @@ +import Foundation +import TesseraTerminal +import TesseraTerminalCore +import Testing + +@testable import TesseraHarness + +@MainActor +struct HarnessCLITests { + private func run( + _ arguments: String..., registry: ApplicationRegistry? = nil, terminal: Bool = false + ) async -> CommandOutput { + await HarnessCLI.run( + arguments: arguments, + registry: registry ?? Fixtures.registry(), + outputIsTerminal: terminal, + configuration: .default + ) + } + + 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 throws { + let output = await run("apps") + + #expect(output.exitCode == 0) + #expect(output.stderr.isEmpty) + #expect(output.stdout.hasSuffix("}\n")) + let object = try #require( + JSONSerialization.jsonObject(with: Data(output.stdout.utf8)) as? [String: Any]) + #expect(object["command"] as? String == "apps") + let apps = try #require(object["apps"] as? [[String: Any]]) + let counter = try #require(apps.first { $0["name"] as? String == "counter" }) + #expect(counter["minimum"] as? String == "20x1") + #expect(counter["viewports"] as? [String] == ["desktop"]) + #expect( + apps.compactMap { $0["name"] as? String } + == apps.compactMap { $0["name"] as? String }.sorted()) + } + + @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) + + #expect(table.stdout.hasPrefix("NAME")) + #expect(table.stdout.contains("counter")) + #expect(table.stdout.contains("20x1")) + #expect(table.stdout.contains("desktop, mobileScreen")) + #expect(forced.stdout.hasPrefix("{")) + } + + @Test + func `--pretty forces the table when stdout is not a terminal`() async { + let output = await run("apps", "--pretty") + + #expect(output.stdout.hasPrefix("NAME")) + } + + @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()) + + #expect(output.exitCode == 0) + #expect(output.stdout == "no applications are registered\n") + } + + @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) + + #expect(apps.exitCode == 3) + #expect(apps.stderr.contains("registered more than once: `counter`")) + #expect(launch.exitCode == 3) + #expect(launch.stderr.contains("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) + + #expect(output.exitCode == 2) + #expect(output.stderr.contains("unknown app `ghost`; accepted: ")) + #expect(output.stderr.contains("counter")) + } + + @Test + func `launch without a name is a usage error`() async { + let output = await run("launch", "--pretty", terminal: true) + + #expect(output.exitCode == 2) + #expect(output.stderr.contains("usage: launch NAME")) + } + + @Test + func `launch refuses to start unless stdout is a terminal`() async throws { + let output = await run("launch", "counter") + + #expect(output.exitCode == 3) + let object = try #require( + JSONSerialization.jsonObject(with: Data(output.stdout.utf8)) as? [String: Any]) + #expect(object["command"] as? String == "launch") + #expect(object["exitCode"] as? Int == 3) + #expect((object["message"] as? String)?.contains("not one") == true) + } + + @Test + func `an unknown command lists the accepted commands`() async { + let output = await run("jump", "--pretty") + + #expect(output.exitCode == 2) + #expect( + output.stderr == "error: unknown command `jump`; accepted: apps, launch, help\n") + } + + @Test + func `no command is a usage error`() async { + let output = await run("--pretty") + + #expect(output.exitCode == 2) + #expect(output.stderr.contains("a command is required")) + } + + @Test + func `help prints real text for each command whatever stdout is`() async { + for command in ["apps", "launch", "help"] { + let output = await run("help", command) + + #expect(output.exitCode == 0) + #expect(output.stdout.hasPrefix("usage: \(command)")) + } + let overview = await run("help") + #expect(overview.stdout.contains("launch NAME")) + #expect(await run("help", "ghost").exitCode == 2) + } + + @Test + func `--json and --pretty together are a usage error`() async { + let output = await run("apps", "--json", "--pretty") + + #expect(output.exitCode == 2) + } +} diff --git a/Tests/TesseraHarnessTests/RegistryTests.swift b/Tests/TesseraHarnessTests/RegistryTests.swift new file mode 100644 index 0000000..b3ad33c --- /dev/null +++ b/Tests/TesseraHarnessTests/RegistryTests.swift @@ -0,0 +1,109 @@ +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") + + #expect(entry.name == "counter") + #expect(entry.minimum == TerminalSize(columns: 20, rows: 1)) + #expect(entry.viewports == [.desktop]) + } + + @Test + func `entries and names come back sorted by name`() throws { + let names = try Fixtures.registry().names() + + #expect(names == names.sorted()) + #expect(names.contains("main-actor-counter")) + } + + @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") + } + + guard case .unknown(let name, let accepted)? = error else { + Issue.record("expected an unknown-name error, got \(String(describing: error))") + return + } + #expect(name == "ghost") + #expect(accepted.contains("counter")) + #expect(error?.description.hasPrefix("unknown app `ghost`; accepted: ") == true) + } + + @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()) + + #expect(report.passed == false) + #expect(report.failures.first?.verb == "app") + #expect(report.failures.first?.message.contains("registered more than once") == true) + } + + @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`() { + #expect(Viewport.desktop.fixedSize == TerminalSize(columns: 80, rows: 24)) + #expect(Viewport.mobileScreen.fixedSize == TerminalSize(columns: 40, rows: 16)) + #expect( + Viewport.custom(TerminalSize(columns: 30, rows: 9)).fixedSize + == TerminalSize(columns: 30, rows: 9)) + #expect(Viewport.mobile.fixedSize == 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)), + ] + + #expect(viewports.count == 4) + #expect( + viewports.map(\.name).sorted() == ["30x9", "desktop", "mobile", "mobileScreen"]) + } +} diff --git a/Tests/TesseraHarnessTests/ScenarioTests.swift b/Tests/TesseraHarnessTests/ScenarioTests.swift index d9d33fa..4af7b3f 100644 --- a/Tests/TesseraHarnessTests/ScenarioTests.swift +++ b/Tests/TesseraHarnessTests/ScenarioTests.swift @@ -1,20 +1,24 @@ import Foundation +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) +} + 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" + """ ) #expect(scenario.app == "stack") @@ -23,10 +27,29 @@ struct ScenarioParserTests { #expect(scenario.lines.first?.number == 4) } + @Test + func `a scenario without an app header is a parse error naming the accepted apps`() { + let error = #expect(throws: ScenarioParseError.self) { + try parse("size desktop\nkey Tab") + } + + #expect(error?.message.contains("`app NAME` header") == true) + #expect(error?.message.contains("accepted: stack, workspace, greedy") == true) + } + + @Test + func `an app the registry does not hold is a parse error`() { + let error = #expect(throws: ScenarioParseError.self) { + try parse("app missing") + } + + #expect(error?.message.contains("unknown app `missing`") == true) + } + @Test func `size after a body statement names the resize verb`() { let error = #expect(throws: ScenarioParseError.self) { - try ScenarioParser.parse("key Tab\nsize 10x10", file: "t.scn") + try parse("key Tab\nsize 10x10") } #expect(error?.line == 2) @@ -37,7 +60,7 @@ struct ScenarioParserTests { @Test func `unknown statements list the accepted vocabulary`() { let error = #expect(throws: ScenarioParseError.self) { - try ScenarioParser.parse("jump 3", file: "t.scn") + try parse("jump 3") } #expect(error?.message.contains("accepted: app, size, resize, key") == true) @@ -46,7 +69,7 @@ struct ScenarioParserTests { @Test func `reserved verbs are reported as not yet expressible`() { let error = #expect(throws: ScenarioParseError.self) { - try ScenarioParser.parse("mouse click 3,4", file: "t.scn") + try parse("mouse click 3,4") } #expect(error?.message.contains("not yet expressible") == true) @@ -55,7 +78,7 @@ struct ScenarioParserTests { @Test func `unknown key names list the accepted set`() { let error = #expect(throws: ScenarioParseError.self) { - try ScenarioParser.parse("key Hyperspace", file: "t.scn") + try parse("key Hyperspace") } #expect(error?.message.contains("ArrowLeft") == true) @@ -63,10 +86,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++") let keys = scenario.lines.compactMap { line -> Key? in if case .key(let key) = line.statement { key } else { nil } } @@ -79,9 +99,8 @@ struct ScenarioParserTests { @Test func `frame expectations accept one token or the plan's 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" ) #expect(scenario.lines[0].statement == scenario.lines[1].statement) @@ -90,7 +109,7 @@ struct ScenarioParserTests { @Test func `unterminated quotes fail with the line number`() { let error = #expect(throws: ScenarioParseError.self) { - try ScenarioParser.parse("\ntype \"oops", file: "t.scn") + try parse("\ntype \"oops") } #expect(error?.line == 2) @@ -99,20 +118,22 @@ struct ScenarioParserTests { @Test func `duplicate shot names are rejected`() { #expect(throws: ScenarioParseError.self) { - try ScenarioParser.parse("shot a\nshot a", file: "t.scn") + try parse("shot a\nshot a") } } } +@MainActor struct ScenarioRunnerTests { - @Test - func `a wrong row expectation reports expected actual and the screen`() throws { - let scenario = try ScenarioParser.parse( - "app stack\nsize 40x10\nexpect text 0 \"Stack demos\"", - file: "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()) + } - let report = ScenarioRunner.run(scenario, name: "t.scn") + @Test + 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\"") #expect(report.passed == false) let failure = try #require(report.failures.first) @@ -122,8 +143,8 @@ struct ScenarioRunnerTests { } @Test - func `the run continues after a failed expectation`() throws { - let scenario = try ScenarioParser.parse( + func `the run continues after a failed expectation`() async throws { + let report = try await run( """ app stack size 40x10 @@ -131,99 +152,129 @@ struct ScenarioRunnerTests { type "x" expect text 2 "Name: x" expect text 5 "wrong again" - """, - file: "t.scn" + """ ) - let report = ScenarioRunner.run(scenario, name: "t.scn") - #expect(report.failures.map(\.line) == [3, 6]) } @Test - func `a locator that matches nothing is a failure that names the node types`() throws { - let scenario = try ScenarioParser.parse( - "app stack\nsize 40x10\nexpect node type=Missing frame 0,0,1x1", - file: "t.scn" - ) - - let report = ScenarioRunner.run(scenario, name: "t.scn") + 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") #expect(report.failures.first?.actual?.contains("VStack") == true) } @Test - func `an ambiguous locator asks for nth`() throws { - let scenario = try ScenarioParser.parse( - "app stack\nsize 40x10\nexpect node type=Text frame 0,0,1x1", - file: "t.scn" - ) - - let report = ScenarioRunner.run(scenario, name: "t.scn") + 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") #expect(report.failures.first?.message.contains("add nth=") == true) } @Test - func `type feeds one key event per character into demo state`() throws { - let scenario = try ScenarioParser.parse( - "app stack\nsize 40x10\ntype \"Rob\"\nkey Backspace\nexpect text 2 \"Name: Ro\"", - file: "t.scn" - ) + 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\"") - #expect(ScenarioRunner.run(scenario, name: "t.scn").passed) + #expect(report.passed) } @Test - func `mobile fails for a demo that fills its proposal`() throws { - let scenario = try ScenarioParser.parse( - "app workspace\nsize mobile\nshot a", - file: "t.scn" - ) + 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") + + #expect(report.passed) + } + + @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") + + let failure = try #require(report.failures.first) + #expect(failure.verb == "size") + #expect(failure.message.contains("mobileScreen")) + #expect(failure.message.contains("`greedy` has no natural height")) + } - let report = ScenarioRunner.run(scenario, name: "t.scn") + @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") #expect(report.failures.first?.verb == "size") } @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") - #expect(stack?.rows == 6) - #expect(stack?.fillsProposal == false) - #expect(workspace?.fillsProposal == true) + #expect(report.passed) + } + + @Test + func `an app the registry lacks fails the run with the app verb`() async throws { + let scenario = Scenario(app: "ghost", viewport: .desktop, lines: []) + + let report = await ScenarioRunner.run( + scenario, name: "t.scn", registry: Fixtures.registry()) + + #expect(report.failures.first?.verb == "app") + #expect(report.failures.first?.message.contains("accepted: ") == true) + } + + @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) + + #expect(stack.rows == 6) + #expect(stack.fillsProposal == false) + #expect(workspace.fillsProposal) + #expect(greedy.fillsProposal) } } +@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) #expect(first.passed) #expect(first.json() == second.json()) } @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") #expect(report.failures.map(\.line) == [6, 7, 8, 10]) } diff --git a/Tests/TesseraRuntimeTests/StepTests.swift b/Tests/TesseraRuntimeTests/StepTests.swift new file mode 100644 index 0000000..444676a --- /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 2087805..627631f 100644 --- a/docs/architecture/developer-tooling.md +++ b/docs/architecture/developer-tooling.md @@ -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 `package` | +| 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,7 @@ 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-devtools` skill | agents | `.agents/skills/tessera-devtools/` | built for the tools above; it grows with each tool | ### Demos @@ -79,6 +79,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 +89,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 +174,8 @@ 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"}`. `launch` refuses to start unless standard output is a terminal (exit 3), 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. + ### 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: diff --git a/docs/spec/module-and-file-layout.md b/docs/spec/module-and-file-layout.md index 92a86f2..f2aa145 100644 --- a/docs/spec/module-and-file-layout.md +++ b/docs/spec/module-and-file-layout.md @@ -147,9 +147,17 @@ 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): apps, launch, help + Driver.swift // main-actor shell over the step (package) + HeadlessHost.swift // a session over a headless device into one virtual terminal (package) + HeadlessTerminalDevice.swift // the device under a headless session (package) + LiveHost.swift // a session over the real terminal (package) + ActorHost.swift // per-test actor shell over the same step (package) + NaturalHeight.swift // the mobile natural-height probe (package) + StandardOutput.swift // the one platform question the CLI asks (package) + Scenarios/ // parser, runner, report TesseraCore/ // declarative view values and explicit graph runtime Views/ @@ -288,7 +296,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 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/mise-tasks/dev/apps b/mise-tasks/dev/apps new file mode 100755 index 0000000..beefd64 --- /dev/null +++ b/mise-tasks/dev/apps @@ -0,0 +1,14 @@ +#!/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" +args=() +if [[ "${usage_json:-}" == "true" ]]; then + args+=(--json) +fi +if [[ "${usage_pretty:-}" == "true" ]]; then + args+=(--pretty) +fi +swift run --only-use-versions-from-resolved-file --package-path Examples tessera apps ${args[@]+"${args[@]}"} diff --git a/mise-tasks/dev/launch b/mise-tasks/dev/launch new file mode 100755 index 0000000..07c4547 --- /dev/null +++ b/mise-tasks/dev/launch @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +#MISE description="Run one registered app in this terminal (tessera launch NAME)" +#USAGE arg "" help="App name, as listed by `mise run dev:apps`" +set -euo pipefail +cd "$MISE_PROJECT_ROOT" +swift run --only-use-versions-from-resolved-file --package-path Examples tessera launch "${usage_name:?}" diff --git a/mise-tasks/quality/architecture b/mise-tasks/quality/architecture index a0b536a..2da6aa4 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 0000000..955d63f --- /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 478d841..35e7445 100755 --- a/scripts/check-package-boundaries.py +++ b/scripts/check-package-boundaries.py @@ -28,12 +28,12 @@ "TesseraHarness": frozenset( { "TesseraCore", - "TesseraLayout", "TesseraRuntime", + "TesseraTerminal", "TesseraTerminalCore", "TesseraTerminalInput", + "TesseraTerminalIO", "TesseraTerminalSnapshotSupport", - "TesseraWidgets", } ), } diff --git a/scripts/test_check_package_boundaries.py b/scripts/test_check_package_boundaries.py index f3cb15f..6bc643a 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", ], ) From b2ee4f13445612c7a55af0dd4fe30e6ec22b7699 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 01:33:02 -0400 Subject: [PATCH 12/37] docs(plans): record Slice 4, the registry, viewports, driver, and tessera launch --- ...hase-4-view-layer-and-developer-tooling.md | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) 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 0f34b8e..d930c32 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -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 745 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. Unverified: the Windows branch of `StandardOutput.swift`, whose first check is the Windows CI leg. - [ ] 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 Slice 3 decisions marked "(owner to weigh)" are reversible and do not block 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 Slice 3 and Slice 4 decisions marked "(owner to weigh)" are reversible and do not block Slice 5. ## Approach @@ -264,6 +264,16 @@ 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) +- 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. If the Phase 5 runtime keeps the spec's loop, a demo that shows its size behaves differently live under the runtime than under the harness; either the runtime delivers the same event or the app gets another route to its size. (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 output is a terminal, so a test or a pipe never opens a live session; a failure is exit 2 for a usage error and 3 for a harness error, as JSON `{command, exitCode, message}` when JSON is on. Only the commands and flags are promised, so that shape is 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. (this plan, Slice 4 run) +- 2026-10-24 The Examples tests do not run in CI yet. `ci.yml` builds the Examples package (so the `tessera` executable and `DemoSupport` compile on every leg) but runs only the root package's tests; the plan gives `swift test --package-path Examples` to Slice 5, so this slice does not add it. The step, the driver, the host shells, the registry, and the CLI are all covered by root-package tests; only the counter's own snapshot tests wait. (this plan, Slice 4 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. ## Discoveries @@ -286,6 +296,16 @@ 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. - 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. +- 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 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-devtools` skill. ## Outcome From e707aa0c4afec75cdcd53253a6d7a4082b5f42a8 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 01:46:36 -0400 Subject: [PATCH 13/37] docs(plans): record the Windows leg of Slice 4's CI --- docs/plans/phase-4-view-layer-and-developer-tooling.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d930c32..e9a8686 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -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. -- [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 745 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. Unverified: the Windows branch of `StandardOutput.swift`, whose first check is the Windows CI leg. +- [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 745 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 `b2ee4f1` (run 40066697257) passed all 14 jobs, including `test (windows-latest)`, which compiles `StandardOutput.swift`'s WinSDK branch and runs the harness tests, and `test (xcode-27)`. - [ ] 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` From 40fea7d1e88067be62893836a024f069c369e683 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 02:28:15 -0400 Subject: [PATCH 14/37] fix(harness): address the Slice 4 review nits apps --json writes minimum: null (and a failure writes command: null) instead of omitting the key. launch checks that stdin is a terminal before the lifecycle starts, so it exits 3 with a clear message instead of failing inside raw mode. The registry's headless path is tested for every adoption shape, including the main-actor app. CI and ci:build-test now run swift test --package-path Examples. The step's doc and the spec say a synchronous nonisolated caller cannot compile the call, and the spec's runtime loop delivers the starting size as a resize, as the driver does. The docs call the driver and its hosts internal, as the code has them. --- .github/workflows/ci.yml | 6 ++ Sources/TesseraHarness/HarnessCLI.swift | 55 +++++++++++++--- Sources/TesseraHarness/StandardOutput.swift | 18 ------ Sources/TesseraHarness/StandardStreams.swift | 28 +++++++++ Sources/TesseraRuntime/Application.swift | 4 +- Sources/TesseraRuntime/Step.swift | 3 +- Tests/TesseraHarnessTests/DriverTests.swift | 18 ++++++ .../TesseraHarnessTests/HarnessCLITests.swift | 62 ++++++++++++++++++- docs/architecture/developer-tooling.md | 4 +- ...hase-4-view-layer-and-developer-tooling.md | 13 ++-- docs/spec/module-and-file-layout.md | 14 ++--- docs/spec/phase-5-runtime-and-polish.md | 13 ++-- mise-tasks/ci/build-test | 3 +- 13 files changed, 186 insertions(+), 55 deletions(-) delete mode 100644 Sources/TesseraHarness/StandardOutput.swift create mode 100644 Sources/TesseraHarness/StandardStreams.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9eddfe4..5b9ebd9 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/Sources/TesseraHarness/HarnessCLI.swift b/Sources/TesseraHarness/HarnessCLI.swift index ed0a1b8..1268547 100644 --- a/Sources/TesseraHarness/HarnessCLI.swift +++ b/Sources/TesseraHarness/HarnessCLI.swift @@ -24,7 +24,8 @@ public enum HarnessCLI { let output = await run( arguments: Array(CommandLine.arguments.dropFirst()), registry: registry, - outputIsTerminal: StandardOutput.isTerminal, + inputIsTerminal: StandardStreams.inputIsTerminal, + outputIsTerminal: StandardStreams.outputIsTerminal, configuration: .default ) if !output.stdout.isEmpty { @@ -46,20 +47,45 @@ struct CommandOutput: Equatable { extension HarnessCLI { private struct AppsListing: 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" } + /// 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 = ["apps", "launch", "help"] @@ -83,8 +109,8 @@ extension HarnessCLI { 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. A name registered twice fails the - command with exit 3. + "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. """, "launch": """ usage: launch NAME @@ -92,7 +118,7 @@ extension HarnessCLI { Runs the named application in this terminal 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 output or input is not a terminal. + when standard input or output is not a terminal. """, "help": """ usage: help [COMMAND] @@ -103,12 +129,14 @@ extension HarnessCLI { /// 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 `outputIsTerminal` - /// is `true`; this keeps a test or a pipe from ever opening a live session. + /// `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. @MainActor static func run( arguments: [String], registry: ApplicationRegistry, + inputIsTerminal: Bool, outputIsTerminal: Bool, configuration: TerminalApplicationConfiguration ) async -> CommandOutput { @@ -138,7 +166,8 @@ extension HarnessCLI { return apps(rest, registry: registry, json: asJSON) case "launch": return await launch( - rest, registry: registry, json: asJSON, outputIsTerminal: outputIsTerminal, + rest, registry: registry, json: asJSON, + terminals: (input: inputIsTerminal, output: outputIsTerminal), configuration: configuration) case "help": return help(rest, json: asJSON) @@ -199,7 +228,7 @@ extension HarnessCLI { _ arguments: [String], registry: ApplicationRegistry, json: Bool, - outputIsTerminal: Bool, + terminals: (input: Bool, output: Bool), configuration: TerminalApplicationConfiguration ) async -> CommandOutput { guard arguments.count == 1, let name = arguments.first, !name.hasPrefix("-") else { @@ -216,9 +245,15 @@ extension HarnessCLI { } return failure(error.description, exitCode: exitCode, command: "launch", json: json) } - guard outputIsTerminal else { + guard terminals.input, terminals.output else { + let missing = + switch 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 failure( - "`launch` runs an application in this terminal, and standard output is not one", + "`launch` runs an application in this terminal, and \(missing)", exitCode: 3, command: "launch", json: json) } do { diff --git a/Sources/TesseraHarness/StandardOutput.swift b/Sources/TesseraHarness/StandardOutput.swift deleted file mode 100644 index 285b684..0000000 --- a/Sources/TesseraHarness/StandardOutput.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation - -#if os(Windows) - import WinSDK -#endif - -/// The one platform question the command line asks: is standard output a terminal. -enum StandardOutput { - /// Whether standard output is attached to a terminal or console. - static var isTerminal: 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/StandardStreams.swift b/Sources/TesseraHarness/StandardStreams.swift new file mode 100644 index 0000000..c8c44b6 --- /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/TesseraRuntime/Application.swift b/Sources/TesseraRuntime/Application.swift index 87f4a04..bff8c55 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 index 07fbef2..ea04c7e 100644 --- a/Sources/TesseraRuntime/Step.swift +++ b/Sources/TesseraRuntime/Step.swift @@ -9,7 +9,8 @@ import TesseraTerminalInput /// 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 nonisolated caller cannot compile the call. +/// 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. /// diff --git a/Tests/TesseraHarnessTests/DriverTests.swift b/Tests/TesseraHarnessTests/DriverTests.swift index c72fa93..8acf4e9 100644 --- a/Tests/TesseraHarnessTests/DriverTests.swift +++ b/Tests/TesseraHarnessTests/DriverTests.swift @@ -134,6 +134,24 @@ struct DriverTests { #expect(rows.count == 12) } + @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] = [] diff --git a/Tests/TesseraHarnessTests/HarnessCLITests.swift b/Tests/TesseraHarnessTests/HarnessCLITests.swift index 7e49292..edf7654 100644 --- a/Tests/TesseraHarnessTests/HarnessCLITests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLITests.swift @@ -7,12 +7,18 @@ import Testing @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 + _ arguments: String..., + registry: ApplicationRegistry? = nil, + terminal: Bool = false, + input: Bool = false ) async -> CommandOutput { await HarnessCLI.run( arguments: arguments, registry: registry ?? Fixtures.registry(), + inputIsTerminal: input, outputIsTerminal: terminal, configuration: .default ) @@ -38,11 +44,27 @@ struct HarnessCLITests { let counter = try #require(apps.first { $0["name"] as? String == "counter" }) #expect(counter["minimum"] as? String == "20x1") #expect(counter["viewports"] as? [String] == ["desktop"]) + #expect(Set(counter.keys) == ["minimum", "name", "viewports"]) #expect( apps.compactMap { $0["name"] as? String } == apps.compactMap { $0["name"] as? String }.sorted()) } + @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" }) + #expect(Set(stack.keys) == ["minimum", "name", "viewports"]) + #expect(stack["minimum"] is NSNull) + #expect(output.stdout.contains(#"{"minimum":null,"name":"greedy""#)) + } + @Test func `apps prints a table on a terminal and --json forces the object`() async { let table = await run("apps", terminal: true) @@ -113,14 +135,48 @@ struct HarnessCLITests { @Test func `launch refuses to start unless stdout is a terminal`() async throws { - let output = await run("launch", "counter") + let output = await run("launch", "counter", input: true) #expect(output.exitCode == 3) let object = try #require( JSONSerialization.jsonObject(with: Data(output.stdout.utf8)) as? [String: Any]) #expect(object["command"] as? String == "launch") #expect(object["exitCode"] as? Int == 3) - #expect((object["message"] as? String)?.contains("not one") == true) + #expect( + (object["message"] as? String)?.hasSuffix("standard output is not one") == true) + } + + @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) + + #expect(output.exitCode == 3) + #expect(output.stdout.isEmpty) + #expect( + output.stderr + == "error: `launch` runs an application in this terminal, and standard input is not one\n" + ) + } + + @Test + func `launch names both streams when neither is a terminal`() async { + let output = await run("launch", "counter", "--pretty") + + #expect(output.exitCode == 3) + #expect(output.stderr.contains("standard input and output are not terminals")) + } + + @Test + func `an unknown command has a null command in its JSON failure`() async throws { + let output = await run("jump") + + let object = try #require( + JSONSerialization.jsonObject(with: Data(output.stdout.utf8)) as? [String: Any]) + #expect(object["command"] is NSNull) + #expect(object["exitCode"] as? Int == 2) } @Test diff --git a/docs/architecture/developer-tooling.md b/docs/architecture/developer-tooling.md index 627631f..409fbd3 100644 --- a/docs/architecture/developer-tooling.md +++ b/docs/architecture/developer-tooling.md @@ -52,7 +52,7 @@ Read the diagram as one scenario run. `tessera run` and `swift test` are two way | --- | --- | --- | --- | | 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` | built: one `Driver` under a headless host, a live host, and a per-test actor host, all `package` | +| 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 | @@ -174,7 +174,7 @@ 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"}`. `launch` refuses to start unless standard output is a terminal (exit 3), 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. +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. ### Stability 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 e9a8686..84572fb 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -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. -- [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 745 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 `b2ee4f1` (run 40066697257) passed all 14 jobs, including `test (windows-latest)`, which compiles `StandardOutput.swift`'s WinSDK branch and runs the harness tests, and `test (xcode-27)`. +- [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` @@ -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. @@ -265,15 +265,15 @@ Rules for an agent running slices of this plan without the owner. - 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) - 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. If the Phase 5 runtime keeps the spec's loop, a demo that shows its size behaves differently live under the runtime than under the harness; either the runtime delivers the same event or the app gets another route to its size. (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 output is a terminal, so a test or a pipe never opens a live session; a failure is exit 2 for a usage error and 3 for a harness error, as JSON `{command, exitCode, message}` when JSON is on. Only the commands and flags are promised, so that shape is free to change. (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. (this plan, Slice 4 run) -- 2026-10-24 The Examples tests do not run in CI yet. `ci.yml` builds the Examples package (so the `tessera` executable and `DemoSupport` compile on every leg) but runs only the root package's tests; the plan gives `swift test --package-path Examples` to Slice 5, so this slice does not add it. The step, the driver, the host shells, the registry, and the CLI are all covered by root-package tests; only the counter's own snapshot tests wait. (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) - 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. ## Discoveries @@ -305,6 +305,7 @@ Rules for an agent running slices of this plan without the owner. - 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-devtools` skill. ## Outcome diff --git a/docs/spec/module-and-file-layout.md b/docs/spec/module-and-file-layout.md index f2aa145..7c37ab9 100644 --- a/docs/spec/module-and-file-layout.md +++ b/docs/spec/module-and-file-layout.md @@ -150,13 +150,13 @@ Sources/ ApplicationRegistry.swift // ApplicationRegistry (public) and its fail-closed reads (package) Viewport.swift // Viewport (public) HarnessCLI.swift // HarnessCLI.main(registry:) (public): apps, launch, help - Driver.swift // main-actor shell over the step (package) - HeadlessHost.swift // a session over a headless device into one virtual terminal (package) - HeadlessTerminalDevice.swift // the device under a headless session (package) - LiveHost.swift // a session over the real terminal (package) - ActorHost.swift // per-test actor shell over the same step (package) - NaturalHeight.swift // the mobile natural-height probe (package) - StandardOutput.swift // the one platform question the CLI asks (package) + 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 diff --git a/docs/spec/phase-5-runtime-and-polish.md b/docs/spec/phase-5-runtime-and-polish.md index f773591..0cede50 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 ffd2bf4..ca72f23 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 From 3faa2f4692e24d8d413910f57091fe44e02010a2 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 03:19:08 -0400 Subject: [PATCH 15/37] feat(harness): add the scenario language, runner, and tessera run Examples/Scenarios/README.md is the scenario grammar's single source: app, size, and term headers; resize, key, type, paste, and bytes input; shot, tree, expect text, expect screen, and expect node frame; shell quoting and # comments; and a table of the forms it names but cannot honour yet, each a parse error that says what it waits for. The parser is header-binding and fail-fast (FILE:LINE: error:), reads CRLF files, and treats an unknown statement as an error. The runner continues after a failed expectation; shot compares a text golden in a directory named like the scenario and fails on drift unless --record writes it. TesseraHarness gains ScenarioRunner, ScenarioReport, and ScenarioFailure as its public run entry point, and HarnessCLI gains run (--record, --repeat N, --timing), shot, tree, check, and replay, with real help text, one JSON object per command off a terminal, and exit codes 0-3. check and replay refuse as not yet available until the projection and the trace exist. The headless device answers a session's colour queries from the term header. mise run dev:run, dev:shot, dev:tree, dev:check, dev:replay, and dev:help mirror the commands. The spike corpus moves to Examples/Scenarios with a counter scenario, the stack and workspace demos it runs against, and its goldens, and CI runs tessera run on every leg of the test matrix, Windows included, with goldens checked out as LF. The testing-posture spec, the Developer tooling page, and the tessera-devtools skill describe what is built. --- .agents/skills/tessera-devtools/SKILL.md | 46 +- .gitattributes | 4 + .github/workflows/ci.yml | 9 + CHANGELOG.md | 4 + CONTRIBUTING.md | 2 + Examples/Scenarios/README.md | 179 +++++++ Examples/Scenarios/counter-basics.scn | 26 + Examples/Scenarios/counter-basics/reset.txt | 5 + .../Scenarios/stack-mobile.scn | 0 Examples/Scenarios/stack-mobile/mobile.txt | 6 + .../Scenarios/workspace-compact.scn | 0 .../Scenarios/workspace-compact/compact.txt | 12 + .../Scenarios/workspace-compact/restored.txt | 24 + .../Scenarios/workspace-desktop.scn | 0 .../Scenarios/workspace-desktop/desktop.txt | 24 + .../Scenarios/workspace-input.scn | 0 .../Scenarios/workspace-input/after-input.txt | 24 + Examples/Sources/DemoSupport/Demos.swift | 2 + Examples/Sources/DemoSupport/Stack.swift | 55 ++ Examples/Sources/DemoSupport/Workspace.swift | 149 ++++++ .../DemoSupportTests/CounterDemoTests.swift | 2 +- .../ScenarioCorpusTests.swift | 36 ++ README.md | 2 +- .../TesseraHarness/ApplicationRegistry.swift | 13 +- .../TesseraHarness/HarnessCLI+Scenarios.swift | 218 ++++++++ Sources/TesseraHarness/HarnessCLI.swift | 96 ++-- Sources/TesseraHarness/HeadlessHost.swift | 43 +- .../HeadlessTerminalDevice.swift | 39 +- Sources/TesseraHarness/HelpText.swift | 103 ++++ .../TesseraHarness/Scenarios/Goldens.swift | 122 +++++ .../TesseraHarness/Scenarios/Grammar.swift | 51 ++ Sources/TesseraHarness/Scenarios/Report.swift | 274 +++++++--- Sources/TesseraHarness/Scenarios/Runner.swift | 239 --------- .../TesseraHarness/Scenarios/Scenario.swift | 392 -------------- .../Scenarios/ScenarioFiles.swift | 84 +++ .../Scenarios/ScenarioParser.swift | 404 +++++++++++++++ .../Scenarios/ScenarioPlayer.swift | 320 ++++++++++++ .../Scenarios/ScenarioRunner.swift | 177 +++++++ .../TesseraHarness/Scenarios/Statement.swift | 115 +++++ .../TesseraHarness/Scenarios/Tokenizer.swift | 100 ++++ Sources/TesseraHarness/TerminalAnswerer.swift | 140 +++++ Sources/TesseraHarness/TerminalProfile.swift | 91 ++++ Tests/TesseraHarnessTests/Fixtures.swift | 22 +- .../HarnessCLIScenarioTests.swift | 360 +++++++++++++ .../TesseraHarnessTests/HarnessCLITests.swift | 6 +- Tests/TesseraHarnessTests/RegistryTests.swift | 8 +- .../ScenarioRunnerTests.swift | 478 ++++++++++++++++++ Tests/TesseraHarnessTests/ScenarioTests.swift | 411 +++++++++++++-- .../ScratchDirectory.swift | 49 ++ .../TerminalAnswererTests.swift | 201 ++++++++ docs/architecture/developer-tooling.md | 50 +- ...hase-4-view-layer-and-developer-tooling.md | 13 + docs/spec/module-and-file-layout.md | 9 +- docs/spec/phase-4/testing-posture.md | 3 +- mise-tasks/ci/build-test | 3 +- mise-tasks/dev/check | 9 + mise-tasks/dev/help | 6 + mise-tasks/dev/replay | 9 + mise-tasks/dev/run | 12 + mise-tasks/dev/shot | 9 + mise-tasks/dev/tree | 9 + 61 files changed, 4422 insertions(+), 877 deletions(-) create mode 100644 Examples/Scenarios/README.md create mode 100644 Examples/Scenarios/counter-basics.scn create mode 100644 Examples/Scenarios/counter-basics/reset.txt rename {Tests/TesseraHarnessTests => Examples}/Scenarios/stack-mobile.scn (100%) create mode 100644 Examples/Scenarios/stack-mobile/mobile.txt rename {Tests/TesseraHarnessTests => Examples}/Scenarios/workspace-compact.scn (100%) create mode 100644 Examples/Scenarios/workspace-compact/compact.txt create mode 100644 Examples/Scenarios/workspace-compact/restored.txt rename {Tests/TesseraHarnessTests => Examples}/Scenarios/workspace-desktop.scn (100%) create mode 100644 Examples/Scenarios/workspace-desktop/desktop.txt rename {Tests/TesseraHarnessTests => Examples}/Scenarios/workspace-input.scn (100%) create mode 100644 Examples/Scenarios/workspace-input/after-input.txt create mode 100644 Examples/Sources/DemoSupport/Stack.swift create mode 100644 Examples/Sources/DemoSupport/Workspace.swift create mode 100644 Examples/Tests/DemoSupportTests/ScenarioCorpusTests.swift create mode 100644 Sources/TesseraHarness/HarnessCLI+Scenarios.swift create mode 100644 Sources/TesseraHarness/HelpText.swift create mode 100644 Sources/TesseraHarness/Scenarios/Goldens.swift create mode 100644 Sources/TesseraHarness/Scenarios/Grammar.swift delete mode 100644 Sources/TesseraHarness/Scenarios/Runner.swift delete mode 100644 Sources/TesseraHarness/Scenarios/Scenario.swift create mode 100644 Sources/TesseraHarness/Scenarios/ScenarioFiles.swift create mode 100644 Sources/TesseraHarness/Scenarios/ScenarioParser.swift create mode 100644 Sources/TesseraHarness/Scenarios/ScenarioPlayer.swift create mode 100644 Sources/TesseraHarness/Scenarios/ScenarioRunner.swift create mode 100644 Sources/TesseraHarness/Scenarios/Statement.swift create mode 100644 Sources/TesseraHarness/Scenarios/Tokenizer.swift create mode 100644 Sources/TesseraHarness/TerminalAnswerer.swift create mode 100644 Sources/TesseraHarness/TerminalProfile.swift create mode 100644 Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift create mode 100644 Tests/TesseraHarnessTests/ScenarioRunnerTests.swift create mode 100644 Tests/TesseraHarnessTests/ScratchDirectory.swift create mode 100644 Tests/TesseraHarnessTests/TerminalAnswererTests.swift create mode 100755 mise-tasks/dev/check create mode 100755 mise-tasks/dev/help create mode 100755 mise-tasks/dev/replay create mode 100755 mise-tasks/dev/run create mode 100755 mise-tasks/dev/shot create mode 100755 mise-tasks/dev/tree diff --git a/.agents/skills/tessera-devtools/SKILL.md b/.agents/skills/tessera-devtools/SKILL.md index a1c7558..834ff04 100644 --- a/.agents/skills/tessera-devtools/SKILL.md +++ b/.agents/skills/tessera-devtools/SKILL.md @@ -1,12 +1,12 @@ --- name: tessera-devtools -description: Drive Tessera's own demos and check them. Use when listing, launching, or snapshot-testing a demo app, adding a demo, running the `tessera` CLI or `mise run dev:*`, or reading what the harness reports about an app. -version: 0.1.0 +description: Drive Tessera's own demos and check them. Use when listing, launching, picturing, or snapshot-testing a demo app, writing or running a scenario, adding a demo, running the `tessera` CLI or `mise run dev:*`, or reading what the harness reports about an app. +version: 0.2.0 --- # Tessera developer tools -The tools run Tessera apps live in a terminal and headlessly in tests, through one driver, so what a test renders is what a terminal shows. [Developer tooling](../../../docs/architecture/developer-tooling.md) is the design and says which tools exist yet. This skill is how to use the ones that do, and it grows with them. Use only the commands below; a command that is not listed does not exist yet. +The tools run Tessera apps live in a terminal and headlessly in tests and scenarios, through one driver, so what a test renders is what a terminal shows. [Developer tooling](../../../docs/architecture/developer-tooling.md) is the design and says which tools exist yet. `Examples/Scenarios/README.md` is the scenario grammar. This skill is how to use the tools that exist, and it grows with them. Use only the commands below; a command that is not listed does not exist yet. ## What exists @@ -14,9 +14,38 @@ The tools run Tessera apps live in a terminal and headlessly in tests, through o | --- | --- | | `mise run dev:apps` | List the registered apps (`tessera apps`): name, minimum size, viewports. | | `mise run dev:launch NAME` | Run one app in this terminal (`tessera launch NAME`) until the app quits. | -| `mise run dev:apps --json` or `--pretty` | Force the JSON object or the text table. | +| `mise run dev:run PATH...` | Run scenario files or directories of them (`tessera run`), with `--record`, `--repeat N`, and `--timing`. | +| `mise run dev:shot NAME [SIZE]` | Print one app's screen as text (`tessera shot`). SIZE is `desktop`, `mobile`, `mobileScreen`, or `WxH`. | +| `mise run dev:tree NAME [SIZE]` | Print one app's node tree, one line per node (`tessera tree`). | +| `mise run dev:help [COMMAND]` | Print real help for a command. | -`tessera` is the executable of `Examples/Sources/TesseraDev`; `mise run dev:*` builds and runs it (`swift run --package-path Examples tessera …`). Standard output is one JSON object when it is not a terminal, and a text table when it is. Exit codes: 0 success, 2 a usage error (an unknown command or app name, with the accepted names listed), 3 a harness error (a name registered twice, or `launch` without a terminal). `tessera help [COMMAND]` prints real text. +Every command takes `--json` or `--pretty` to force the JSON object or the text form. `tessera` is the executable of `Examples/Sources/TesseraDev`; `mise run dev:*` builds and runs it (`swift run --package-path Examples tessera …`). Standard output is one JSON object when it is not a terminal, and text when it is. Exit codes: 0 success, 1 an expectation failed, 2 a usage or parse error (an unknown command, app name, or flag, a bad path, a scenario that does not parse), 3 a harness error (a name registered twice, `launch` without a terminal, a golden that cannot be written). + +`mise run dev:check` and `mise run dev:replay` exist and exit 2 with "not yet available": there are no invariant rules and no recorded traces yet. Do not read their refusal as a pass, and do not write a `check` statement in a scenario; the parser refuses it by name. + +## Run a scenario + +```sh +mise run dev:run Examples/Scenarios --pretty +mise run dev:run Examples/Scenarios/counter-basics.scn +``` + +A directory runs every `.scn` under it in sorted order. With `--pretty`, or on a terminal, each scenario prints `PASS` or `FAIL` and each failure's line, statement, expected and actual values, and the screen. Without a terminal the output is one JSON object, `{"command": "run", "exitCode", "scenarios": [REPORT...]}`, and each report is `{"failures", "observations", "passed", "scenario"}`. Read a failure from its `line` and `statement` (the line of the scenario file), its `message`, and `expected` against `actual`; `screen` is the whole screen at that moment. Two runs give the same bytes. + +A failure's `verb` is the kind of failure. A statement's own verb (`expect text`, `expect screen`, `expect node`, `shot`) means the app did not do what the scenario said; `size` means the app cannot take the size (`mobile` needs content with a natural height, so use `mobileScreen` for an app that fills the screen); `parse` means the scenario file is wrong at that line, and the message names what is accepted; `repeat` means a run differed from the first under `--repeat N`. + +`mise run dev:shot counter mobile --pretty` and `mise run dev:tree counter mobile --pretty` picture one app without writing a scenario, which is the quickest way to see a failing state before writing the expectation that pins it. + +## Write a scenario + +A scenario is `Examples/Scenarios/NAME.scn`: an `app`, a `size`, an optional `term`, then input and assertions. Read `Examples/Scenarios/README.md` for the grammar; the corpus beside it is the examples to copy. + +1. Picture the state with `dev:shot` or `dev:tree`. +2. Write the statements: `key`, `type`, `paste`, `bytes`, `resize` to drive; `expect text`, `expect screen < ApplicationRegistry { var registry = ApplicationRegistry() register(Counter.self, into: ®istry) + register(Stack.self, into: ®istry) + register(Workspace.self, into: ®istry) return registry } diff --git a/Examples/Sources/DemoSupport/Stack.swift b/Examples/Sources/DemoSupport/Stack.swift new file mode 100644 index 0000000..a7aa8e9 --- /dev/null +++ b/Examples/Sources/DemoSupport/Stack.swift @@ -0,0 +1,55 @@ +import Tessera +import TesseraHarness + +/// Stacks of natural-height content: the demo whose root has a height of its own, so +/// `size mobile` has one to use. +/// +/// Typed characters appear after `Name:`, `Backspace` removes one, and the last line shows the +/// width the demo was last told it has. +final class Stack: Demo { + static let name = "stack" + static let minimum = TerminalSize(columns: 20, rows: 6) + static let viewports: [Viewport] = [.desktop, .mobile] + static let fixtures: [DemoFixture] = [] + + private var size = TerminalSize(columns: 80, rows: 24) + private var typed = "" + + var body: some View { + VStack(spacing: 0) { + Text("Stack demo") + Divider() + HStack(spacing: 1) { + Text("Name:") + Text(typed.isEmpty ? "-" : typed) + } + HStack(spacing: 0) { + Text("left") + Spacer() + Text("right") + } + Divider() + Text("\(size.columns) columns") + } + } + + init() {} + + func apply(_ event: InputEvent) { + switch event { + case .resize(let newSize): + size = newSize + case .key(let key): + switch key.code { + case .character(let character): + typed.append(character) + case .backspace: + _ = typed.popLast() + default: + break + } + default: + break + } + } +} diff --git a/Examples/Sources/DemoSupport/Workspace.swift b/Examples/Sources/DemoSupport/Workspace.swift new file mode 100644 index 0000000..43626e1 --- /dev/null +++ b/Examples/Sources/DemoSupport/Workspace.swift @@ -0,0 +1,149 @@ +import Tessera +import TesseraHarness + +/// A header, a split view of a scrolling catalog beside an inspector, and a status line: the +/// demo that fills any height it is offered, so it runs at `mobileScreen` and not `mobile`. +/// +/// Typed characters, the last key, and `Enter` presses show in the inspector; `Tab` and +/// `Shift+Tab` move a pane marker in the status line, `ArrowDown`, `ArrowUp`, `PageDown`, and +/// `PageUp` scroll the catalog, and `Ctrl+C` marks the status line `quit`. Below 60 columns +/// the catalog pane collapses and the inspector takes the width. +final class Workspace: Demo { + static let name = "workspace" + static let minimum = TerminalSize(columns: 40, rows: 10) + static let viewports: [Viewport] = [.desktop, .mobileScreen] + static let fixtures: [DemoFixture] = [] + + private static let catalogRows = 30 + private static let catalogText = (0.. String { + var parts: [String] = [] + for (name, flag) in [ + ("Ctrl", Modifiers.control), ("Alt", .alt), ("Meta", .meta), ("Shift", .shift), + ] where key.modifiers.contains(flag) { + parts.append(name) + } + switch key.code { + case .character(let character): + parts.append(character == " " ? "Space" : String(character)) + case .tab: parts.append("Tab") + case .enter: parts.append("Enter") + case .escape: parts.append("Escape") + case .backspace: parts.append("Backspace") + case .delete: parts.append("Delete") + case .up: parts.append("ArrowUp") + case .down: parts.append("ArrowDown") + case .left: parts.append("ArrowLeft") + case .right: parts.append("ArrowRight") + case .home: parts.append("Home") + case .end: parts.append("End") + case .pageUp: parts.append("PageUp") + case .pageDown: parts.append("PageDown") + case .function(let number): parts.append("F\(number)") + default: parts.append("?") + } + return parts.joined(separator: "+") + } + + func apply(_ event: InputEvent) { + switch event { + case .resize(let newSize): + size = newSize + case .key(let key): + lastKey = Self.describe(key) + applyKey(key) + default: + break + } + } + + private func applyKey(_ key: Key) { + if key.modifiers.contains(.control), key.code == .character("c") { + quitRequested = true + return + } + switch key.code { + case .character(let character) + where key.modifiers.isDisjoint(with: [.control, .alt, .meta]): + typed.append(character) + case .backspace: + _ = typed.popLast() + case .enter: + activations += 1 + case .tab: + let step = key.modifiers.contains(.shift) ? paneNames.count - 1 : 1 + focusedPane = (focusedPane + step) % paneNames.count + case .down: + scroll(by: 1) + case .up: + scroll(by: -1) + case .pageDown: + scroll(by: 10) + case .pageUp: + scroll(by: -10) + default: + break + } + } + + private func scroll(by delta: Int) { + let row = min(max(scrollOffset.row + delta, 0), Self.catalogRows - 1) + scrollOffset = TerminalPosition(column: 0, row: row) + } +} diff --git a/Examples/Tests/DemoSupportTests/CounterDemoTests.swift b/Examples/Tests/DemoSupportTests/CounterDemoTests.swift index 9938066..1f05739 100644 --- a/Examples/Tests/DemoSupportTests/CounterDemoTests.swift +++ b/Examples/Tests/DemoSupportTests/CounterDemoTests.swift @@ -75,6 +75,6 @@ struct CounterDemoTests { func `the demo registry has no duplicate names`() throws { let names = try Demos.registry().names() - #expect(names == ["counter", "counter-high"]) + #expect(names == ["counter", "counter-high", "stack", "workspace"]) } } diff --git a/Examples/Tests/DemoSupportTests/ScenarioCorpusTests.swift b/Examples/Tests/DemoSupportTests/ScenarioCorpusTests.swift new file mode 100644 index 0000000..595c07f --- /dev/null +++ b/Examples/Tests/DemoSupportTests/ScenarioCorpusTests.swift @@ -0,0 +1,36 @@ +import DemoSupport +import Testing +import TesseraHarness + +/// The corpus CI runs with `tessera run Examples/Scenarios`, run again here through the Swift +/// entry point an app author's own tests use. +@MainActor +struct ScenarioCorpusTests { + /// `Examples/Scenarios`, found from this file's own path: three components up (the file, + /// `DemoSupportTests`, and `Tests`), split at either separator so a Windows path works. + private static let corpus: String = { + var parts = #filePath.split(omittingEmptySubsequences: false) { $0 == "/" || $0 == "\\" } + parts.removeLast(3) + return (parts + ["Scenarios"]).joined(separator: "/") + }() + + @Test + func `every scenario in the corpus passes against its committed goldens`() async { + let reports = await ScenarioRunner(registry: Demos.registry()).run(Self.corpus) + + let failed = reports.filter { !$0.passed } + #expect(failed.isEmpty, "\(failed.map(\.description).joined(separator: "\n"))") + #expect(reports.count >= 5) + } + + @Test + func `two runs of the corpus produce the same bytes`() async { + let runner = ScenarioRunner(registry: Demos.registry()) + + let first = await runner.run(Self.corpus).map(\.json) + let second = await runner.run(Self.corpus).map(\.json) + + #expect(first == second) + #expect(!first.isEmpty) + } +} diff --git a/README.md b/README.md index 354b4b9..bd78291 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` | Register, launch, and drive Tessera apps from test and development targets: `ApplicationRegistry`, `Viewport`, and `HarnessCLI` (the scenario runner is not public yet). | +| `TesseraHarness` | Register, launch, drive, and check Tessera apps from test and development targets: `ApplicationRegistry`, `Viewport`, `HarnessCLI`, and `ScenarioRunner`, which runs text scenarios and returns `ScenarioReport`s (`ScenarioFailure` describes each failed statement). | ## Roadmap diff --git a/Sources/TesseraHarness/ApplicationRegistry.swift b/Sources/TesseraHarness/ApplicationRegistry.swift index fbcfdac..8fd5fbc 100644 --- a/Sources/TesseraHarness/ApplicationRegistry.swift +++ b/Sources/TesseraHarness/ApplicationRegistry.swift @@ -26,7 +26,8 @@ public struct ApplicationRegistry { let headless: @MainActor ( - Viewport, @escaping @MainActor (any ScenarioHost) async throws -> Void + Viewport, TerminalProfile, + @escaping @MainActor (any ScenarioHost) async throws -> Void ) async throws -> Void let live: @MainActor (TerminalApplicationConfiguration) async throws -> Void } @@ -56,9 +57,9 @@ public struct ApplicationRegistry { name: name, minimum: minimum, viewports: viewports, - headless: { viewport, body in + headless: { viewport, profile, body in try await HeadlessHost.run( - name: name, viewport: viewport, make: make, body: body) + name: name, viewport: viewport, profile: profile, make: make, body: body) }, live: { configuration in try await LiveHost.run(make: make, configuration: configuration) @@ -121,7 +122,8 @@ extension ApplicationRegistry { } extension ApplicationRegistry { - /// Runs `body` against the application registered as `name`, headless at `viewport`. + /// Runs `body` against the application registered as `name`, headless at `viewport`, on a + /// terminal described by `terminal`. /// /// 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. @@ -129,8 +131,9 @@ extension ApplicationRegistry { package func withHeadlessHost( _ name: String, at viewport: Viewport, + terminal: TerminalProfile = TerminalProfile(), _ body: @escaping @MainActor (any ScenarioHost) async throws -> Void ) async throws { - try await entry(named: name).headless(viewport, body) + try await entry(named: name).headless(viewport, terminal, body) } } diff --git a/Sources/TesseraHarness/HarnessCLI+Scenarios.swift b/Sources/TesseraHarness/HarnessCLI+Scenarios.swift new file mode 100644 index 0000000..f824f82 --- /dev/null +++ b/Sources/TesseraHarness/HarnessCLI+Scenarios.swift @@ -0,0 +1,218 @@ +import Foundation + +/// The arguments of one command: positional words, flags, and flags that take a value. +struct CommandArguments { + struct Failure: Error { + var message: String + } + + private(set) var positional: [String] = [] + private(set) var flags: Set = [] + private(set) var values: [String: String] = [:] + + /// Splits `arguments`, accepting only the named flags. + /// + /// - Parameters: + /// - arguments: The words after the command. + /// - flags: The flags that stand alone. + /// - valued: The flags followed by their value. + init( + _ arguments: [String], flags allowed: Set = [], valued: Set = [] + ) throws(Failure) { + var iterator = arguments.makeIterator() + while let argument = iterator.next() { + if allowed.contains(argument) { + flags.insert(argument) + } else if valued.contains(argument) { + guard let value = iterator.next() else { + throw Failure(message: "`\(argument)` needs a value") + } + guard values.updateValue(value, forKey: argument) == nil else { + throw Failure(message: "`\(argument)` is given twice") + } + } else if argument.hasPrefix("-"), argument.count > 1 { + let accepted = (allowed.union(valued).union(["--json", "--pretty"])).sorted() + throw Failure( + message: + "unknown flag `\(argument)`; accepted: \(accepted.joined(separator: ", "))") + } else { + positional.append(argument) + } + } + } +} + +extension HarnessCLI { + /// `run`'s JSON object: the command's own status and one report per scenario. + private struct RunResult: Encodable { + var command = "run" + var exitCode: Int32 + var scenarios: [ReportBody] + } + + /// `shot`'s JSON object. + private struct ShotResult: Encodable { + var app: String + var command = "shot" + var exitCode: Int32 = 0 + var rows: [String] + var size: String + } + + /// `tree`'s JSON object. + private struct TreeResult: Encodable { + var app: String + var command = "tree" + var exitCode: Int32 = 0 + var lines: [String] + var size: String + } + + /// The exit code for a set of reports, the highest any failure asks for: 3 for a `harness` + /// failure, 2 for `parse` and `usage`, and 1 for every other failure, which is an + /// expectation that did not hold. + static func exitCode(for reports: [ScenarioReport]) -> Int32 { + reports.flatMap(\.failures).map { failure -> Int32 in + switch failure.verb { + case "harness": 3 + case "parse", "usage": 2 + default: 1 + } + }.max() ?? 0 + } + + // MARK: run + + @MainActor + static func runScenarios( + _ arguments: [String], registry: ApplicationRegistry, json: Bool + ) async -> CommandOutput { + let parsed: CommandArguments + do { + parsed = try CommandArguments( + arguments, flags: ["--record", "--timing"], valued: ["--repeat"]) + } catch { + return usage(error.message, command: "run", json: json) + } + guard !parsed.positional.isEmpty else { + return usage( + "usage: run PATH... [--record] [--repeat N] [--timing]", command: "run", + json: json) + } + var runner = ScenarioRunner( + registry: registry, record: parsed.flags.contains("--record")) + runner.measuresTime = parsed.flags.contains("--timing") + if let value = parsed.values["--repeat"] { + guard let count = Int(value), count >= 1 else { + return usage( + "`--repeat` needs a whole number of at least 1, got `\(value)`", command: "run", + json: json) + } + runner.repetitions = count + } + + let reports = await runner.run(paths: parsed.positional) + let code = exitCode(for: reports) + if json { + return CommandOutput( + exitCode: code, + stdout: encode(RunResult(exitCode: code, scenarios: reports.map(ReportBody.init))) + + "\n") + } + let failed = reports.filter { !$0.passed }.count + var lines = reports.map(\.description) + lines.append( + "\(reports.count) \(reports.count == 1 ? "scenario" : "scenarios"), " + + "\(reports.count - failed) passed, \(failed) failed") + return CommandOutput(exitCode: code, stdout: lines.joined(separator: "\n") + "\n") + } + + // MARK: shot and tree + + @MainActor + static func picture( + _ command: String, _ arguments: [String], registry: ApplicationRegistry, json: Bool + ) async -> CommandOutput { + let target: (name: String, viewport: Viewport) + do { + target = try appAndSize(arguments, command: command) + } catch { + return usage(error.message, command: command, json: json) + } + + let picture: ScenarioPlayer.Picture + do { + picture = try await ScenarioPlayer.picture( + of: target.name, at: target.viewport, registry: registry) + } catch let error as RegistryError { + return registryFailure(error, command: command, json: json) + } catch let error as HostError { + // A size the app cannot take is a mistake in the command, not a failed expectation. + return failure( + error.message, exitCode: error.verb == "size" ? 2 : 3, command: command, + json: json) + } catch { + return failure("\(error)", exitCode: 3, command: command, json: json) + } + + let size = "\(picture.size.columns)x\(picture.size.rows)" + if command == "shot" { + return CommandOutput( + exitCode: 0, + stdout: json + ? encode(ShotResult(app: target.name, rows: picture.rows, size: size)) + "\n" + : GoldenStore.text(of: picture.rows)) + } + return CommandOutput( + exitCode: 0, + stdout: json + ? encode(TreeResult(app: target.name, lines: picture.tree, size: size)) + "\n" + : picture.tree.map { $0 + "\n" }.joined()) + } + + /// `NAME [SIZE]`, the words `shot`, `tree`, and `check` take. + private static func appAndSize(_ arguments: [String], command: String) + throws(CommandArguments.Failure) -> (name: String, viewport: Viewport) + { + let parsed = try CommandArguments(arguments) + guard (1...2).contains(parsed.positional.count) else { + throw CommandArguments.Failure(message: "usage: \(command) NAME [SIZE]") + } + let size = parsed.positional.dropFirst().first ?? "desktop" + guard let viewport = ScenarioParser.viewport(spelled: size) else { + throw CommandArguments.Failure( + message: "bad size `\(size)`; expected WxH, desktop, mobile, or mobileScreen") + } + return (parsed.positional[0], viewport) + } + + // MARK: check and replay + + static func check(_ arguments: [String], json: Bool) -> CommandOutput { + do { + _ = try appAndSize(arguments, command: "check") + } catch { + return usage(error.message, command: "check", json: json) + } + return usage( + "`check` is not yet available: it runs invariant rules over the projection, and the " + + "harness builds neither yet, so it would pass without checking anything", + command: "check", json: json) + } + + static func replay(_ arguments: [String], json: Bool) -> CommandOutput { + let parsed: CommandArguments + do { + parsed = try CommandArguments(arguments, valued: ["--app"]) + } catch { + return usage(error.message, command: "replay", json: json) + } + guard parsed.positional.count == 1, parsed.values["--app"] != nil else { + return usage("usage: replay FILE --app NAME", command: "replay", json: json) + } + return usage( + "`replay` is not yet available: it reruns a recorded trace, and the harness records " + + "no traces yet", + command: "replay", json: json) + } +} diff --git a/Sources/TesseraHarness/HarnessCLI.swift b/Sources/TesseraHarness/HarnessCLI.swift index 1268547..6966bd3 100644 --- a/Sources/TesseraHarness/HarnessCLI.swift +++ b/Sources/TesseraHarness/HarnessCLI.swift @@ -12,13 +12,18 @@ import TesseraTerminal /// ``` /// /// 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. +/// terminal), `run PATH...` (run scenario files, with `--record`, `--repeat N`, and +/// `--timing`), `shot NAME [SIZE]` and `tree NAME [SIZE]` (print one application's screen or +/// node tree), `check` and `replay` (named, and not yet available), and `help [COMMAND]`. +/// +/// Exit codes are 0 for success, 1 when an expectation failed, 2 for a usage or parse 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. 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. + /// - Parameter registry: The applications the commands can list, launch, and run + /// scenarios against. @MainActor public static func main(registry: ApplicationRegistry) async -> Never { let output = await run( @@ -88,43 +93,8 @@ extension HarnessCLI { } } - static let commands = ["apps", "launch", "help"] - - private static let overview = """ - usage: COMMAND [ARGUMENT] - - commands: - apps list the registered applications - launch NAME run one application in this terminal - help [COMMAND] describe a command - - 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. - """ - - private static let helpText: [String: String] = [ - "apps": """ - usage: apps [--json | --pretty] - - 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. - """, - "launch": """ - usage: launch NAME - - Runs the named application in this terminal 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. - """, - "help": """ - usage: help [COMMAND] - - Prints the commands, or one command's usage. - """, + static let commands = [ + "apps", "launch", "run", "shot", "tree", "check", "replay", "help", ] /// Runs one command and returns what it printed, without touching the process. @@ -169,6 +139,14 @@ extension HarnessCLI { rest, registry: registry, json: asJSON, terminals: (input: inputIsTerminal, output: outputIsTerminal), configuration: configuration) + case "run": + return await runScenarios(rest, registry: registry, json: asJSON) + case "shot", "tree": + return await picture(command, rest, registry: registry, json: asJSON) + case "check": + return check(rest, json: asJSON) + case "replay": + return replay(rest, json: asJSON) case "help": return help(rest, json: asJSON) default: @@ -238,12 +216,7 @@ extension HarnessCLI { do { entry = try registry.entry(named: name) } catch { - let exitCode: Int32 = - switch error { - case .duplicate: 3 - case .unknown: 2 - } - return failure(error.description, exitCode: exitCode, command: "launch", json: json) + return registryFailure(error, command: "launch", json: json) } guard terminals.input, terminals.output else { let missing = @@ -268,12 +241,12 @@ extension HarnessCLI { private static func help(_ arguments: [String], json: Bool) -> CommandOutput { guard let topic = arguments.first else { - return CommandOutput(exitCode: 0, stdout: overview + "\n") + return CommandOutput(exitCode: 0, stdout: HelpText.overview + "\n") } guard arguments.count == 1 else { return usage("usage: help [COMMAND]", command: "help", json: json) } - guard let text = helpText[topic] else { + guard let text = HelpText.commands[topic] else { return usage( "unknown command `\(topic)`; accepted: \(commands.joined(separator: ", "))", command: "help", json: json) @@ -283,21 +256,28 @@ extension HarnessCLI { // MARK: Output - private 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 encode(_ value: some Encodable) -> String { + JSONText.string(for: value) } - private static func usage(_ message: String, command: String?, json: Bool) + static func usage(_ message: String, command: String?, json: Bool) -> CommandOutput { + failure(message, exitCode: 2, command: command, json: json) + } + + /// The failure for a name the registry cannot answer: an unknown name is a usage error and + /// a duplicate registration a harness error. + static func registryFailure(_ error: RegistryError, command: String, json: Bool) -> CommandOutput { - failure(message, exitCode: 2, command: command, json: json) + let exitCode: Int32 = + switch error { + case .duplicate: 3 + case .unknown: 2 + } + return failure(error.description, exitCode: exitCode, command: command, json: json) } - private static func failure( + static func failure( _ message: String, exitCode: Int32, command: String?, json: Bool ) -> CommandOutput { if json { diff --git a/Sources/TesseraHarness/HeadlessHost.swift b/Sources/TesseraHarness/HeadlessHost.swift index d34048b..d7d358f 100644 --- a/Sources/TesseraHarness/HeadlessHost.swift +++ b/Sources/TesseraHarness/HeadlessHost.swift @@ -47,15 +47,6 @@ extension ScreenSnapshot { /// 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 @@ -89,19 +80,27 @@ final class HeadlessHost: ScenarioHost { /// 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. + /// The session renders with the profile's colour level and gets the profile's colours when + /// it asks for them. 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, + profile: TerminalProfile = TerminalProfile(), 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 device = HeadlessTerminalDevice(size: size, theme: profile.theme) let terminal = VirtualTerminal.ghosttyOrUnavailable( cols: size.columns, rows: size.rows) + // No terminal modes and no synchronized output: a headless frame is plain cells. + let configuration = TerminalApplicationConfiguration( + modes: [], + synchronizedOutput: .disabled, + colorCapability: profile.colors.override + ) // 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. @@ -110,14 +109,20 @@ final class HeadlessHost: ScenarioHost { 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) + do { + try await TerminalSession.withApplicationTerminal( + configuration: configuration, + io: PlatformIO(terminalDevice: device.seam), + environment: [:] + ) { session in + session.outputTap = { terminal.feed($0) } + try await scope(session) + } + } catch { + await device.finish() + throw error } + await device.finish() } func send(_ event: InputEvent) async throws { diff --git a/Sources/TesseraHarness/HeadlessTerminalDevice.swift b/Sources/TesseraHarness/HeadlessTerminalDevice.swift index 75da9d8..30011ac 100644 --- a/Sources/TesseraHarness/HeadlessTerminalDevice.swift +++ b/Sources/TesseraHarness/HeadlessTerminalDevice.swift @@ -1,33 +1,58 @@ import TesseraTerminalCore import TesseraTerminalIO -/// The terminal device under a headless session: it reports a size and accepts writes. +/// The terminal device under a headless session: it reports a size, accepts writes, and +/// answers the terminal queries a session sends. /// -/// 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. +/// 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. What the device reads is the answer to a query, produced by its +/// ``TerminalAnswerer`` from the profile's theme, so the colours a session learns come from +/// the scenario and never from the virtual terminal's own defaults. 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 let answerer: TerminalAnswerer + private let replies: AsyncStream<[UInt8]> + private let repliesContinuation: AsyncStream<[UInt8]>.Continuation private var size: TerminalSize /// The device seam a `PlatformIO` is built over. nonisolated var seam: TerminalDevice { TerminalDevice( + bytes: { self.replies }, size: { await self.size }, - write: { $0.count } + write: { bytes in + await self.answer(bytes) + return bytes.count + } ) } - /// Creates a device that reports `size`. - init(size: TerminalSize) { + /// Creates a device that reports `size` and answers colour queries from `theme`. + init(size: TerminalSize, theme: TerminalTheme? = nil) { self.size = size + answerer = TerminalAnswerer(theme: theme) + (replies, repliesContinuation) = AsyncStream.makeStream(of: [UInt8].self) } /// Changes the size the session reports from now on. func resize(to size: TerminalSize) { self.size = size } + + /// Ends the device's input, once the session that read it has closed. + func finish() { + repliesContinuation.finish() + } + + private func answer(_ written: ArraySlice) { + let reply = answerer.replies(to: written) + if !reply.isEmpty { + repliesContinuation.yield(reply) + } + } } diff --git a/Sources/TesseraHarness/HelpText.swift b/Sources/TesseraHarness/HelpText.swift new file mode 100644 index 0000000..91dec67 --- /dev/null +++ b/Sources/TesseraHarness/HelpText.swift @@ -0,0 +1,103 @@ +/// What `help` prints: the overview, and one description for each command. +enum HelpText { + static let overview = """ + usage: COMMAND [ARGUMENT...] + + commands: + apps list the registered applications + launch NAME run one application in this terminal + run PATH... run scenario files, or directories of them + shot NAME [SIZE] print one application's screen as text + tree NAME [SIZE] print one application's node tree + check NAME check one application against the invariant rules (not yet available) + replay FILE rerun a recorded trace (not yet available) + help [COMMAND] describe a command + + Every command exits 0 on success, 1 when an expectation failed, 2 on a usage or parse + 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. + """ + + static let commands: [String: String] = [ + "apps": """ + usage: apps [--json | --pretty] + + 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. + """, + "launch": """ + usage: launch NAME + + Runs the named application in this terminal 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. + """, + "run": """ + usage: run PATH... [--record] [--repeat N] [--timing] [--json | --pretty] + + Runs scenario files. A PATH is a .scn file, or a directory whose .scn files, at any + depth, run in sorted order. A path that is neither, or a directory with no scenarios, is + a usage error. Each scenario starts a fresh application. The grammar is in + Examples/Scenarios/README.md. + + --record write the golden files that `shot` statements compare against, instead + of failing when one differs or is missing, and list what changed. Review + the files it wrote; never record to make a failing run pass. + --repeat N run each scenario N times, and fail a run whose report differs from the + first. + --timing add the wall-clock seconds of each run to the report. Times differ from + run to run, so they are never golden. + + Exit codes: 0 every scenario passed, 1 an expectation failed, 2 a scenario did not parse + or a path is not a scenario, 3 a harness error. When several apply, the highest wins. + + The JSON form is {"command": "run", "exitCode", "scenarios": [REPORT...]} with one + REPORT per scenario: {"failures": [{"line", "statement", "verb", "message"} and, when + the statement compares values, "expected", "actual", and "screen"], "observations": + [{"line", "verb", "name", "lines"}], "passed", "recorded" (only when --record wrote + goldens), "scenario", "seconds" (only with --timing)}. A scenario that does not parse is + a failure with verb "parse". Fields are added, never removed or renamed. + """, + "shot": """ + usage: shot NAME [SIZE] [--json | --pretty] + + Prints the screen of the registered application NAME, as text, the way a golden file + holds it. SIZE is desktop, mobile, mobileScreen, or WxH, and defaults to desktop. The + JSON form is {"app", "command": "shot", "exitCode", "rows", "size"}, where "size" is the + terminal size the application was shown at. An unknown NAME is a usage error that lists + the registered names, and so is a size the application cannot take, such as `mobile` for + an application whose content fills any height: the error says which size to use. + """, + "tree": """ + usage: tree NAME [SIZE] [--json | --pretty] + + Prints the node tree of the registered application NAME, one line per node with its + identity, type, frame, and clip. SIZE is desktop, mobile, mobileScreen, or WxH, and + defaults to desktop. The JSON form is {"app", "command": "tree", "exitCode", "lines", + "size"}. The tree's text is for reading and is not promised to stay as it is. Errors are + those of `shot`. + """, + "check": """ + usage: check NAME [SIZE] + + Not yet available. `check` runs invariant rules over an application's node tree, and no + rule exists until the harness builds the projection they read. The command exits 2 and + says so, rather than reporting a pass it did not check; a scenario's `check` statement is + refused the same way. + """, + "replay": """ + usage: replay FILE --app NAME + + Not yet available. `replay` reruns a recorded trace as a scenario, and the harness + records no traces yet. The command exits 2 and says so. + """, + "help": """ + usage: help [COMMAND] + + Prints the commands, or one command's usage. + """, + ] +} diff --git a/Sources/TesseraHarness/Scenarios/Goldens.swift b/Sources/TesseraHarness/Scenarios/Goldens.swift new file mode 100644 index 0000000..b78ba4b --- /dev/null +++ b/Sources/TesseraHarness/Scenarios/Goldens.swift @@ -0,0 +1,122 @@ +import Foundation + +/// The committed text goldens of one scenario: a `NAME.txt` file per `shot`, in a directory +/// named like the scenario file without its extension (`counter.scn` keeps its goldens in +/// `counter/`). +/// +/// A golden is the screen as text: every row with its trailing spaces removed, trailing empty +/// rows dropped, rows joined by `\n`, and one `\n` at the end. Comparison is byte for byte, +/// so drift fails the `shot` statement; recording rewrites the files and says which changed. +struct GoldenStore: Equatable, Sendable { + /// What checking one screen against its golden decided. + enum Outcome: Equatable { + /// The golden holds this screen. + case matches + + /// `record` wrote the golden, which was missing or different. + case recorded(GoldenChange) + + /// The screen differs from the golden. + case drifted(message: String, expected: String, actual: String) + + /// There is nothing to compare the screen with: the golden is missing or is not an LF + /// file. + case unusable(message: String) + + /// The golden could not be written. + case failed(message: String) + } + + /// The directory holding the scenario's goldens, as the run was given the scenario's path. + var directory: String + + /// Whether to write goldens instead of failing on drift. + var record: Bool + + /// The store for the scenario file at `path`, which ends in `.scn`. + init(scenarioPath path: String, record: Bool) { + directory = String(path.dropLast(ScenarioFiles.fileExtension.count)) + self.record = record + } + + /// The text of a screen as a golden holds it. + static func text(of rows: [String]) -> String { + rows.dropTrailingEmptyRows().joined(separator: "\n") + "\n" + } + + func path(for name: String) -> String { + "\(directory)/\(name).txt" + } + + /// Checks `rows`, the screen after statement `name`, against its golden, or records it. + func check(shot name: String, rows: [String]) -> Outcome { + let path = path(for: name) + let text = Self.text(of: rows) + let bytes = Data(text.utf8) + let existing = FileManager.default.contents(atPath: path) + if existing == bytes { + return .matches + } + if record { + do { + try FileManager.default.createDirectory( + atPath: directory, withIntermediateDirectories: true) + try bytes.write(to: URL(fileURLWithPath: path), options: .atomic) + } catch { + return .failed(message: "cannot write the golden `\(path)`: \(error)") + } + return .recorded( + GoldenChange(path: path, change: existing == nil ? .created : .updated)) + } + guard let existing else { + return .unusable( + message: + "shot `\(name)` has no golden at `\(path)`; " + + "run with --record to create it, and review the file") + } + guard let goldenText = String(validating: existing, as: UTF8.self) else { + return .unusable(message: "golden `\(path)` is not UTF-8 text") + } + return drift(shot: name, path: path, goldenText: goldenText, text: text) + } + + private func drift(shot name: String, path: String, goldenText: String, text: String) + -> Outcome + { + if goldenText.replacingOccurrences(of: "\r\n", with: "\n") == text { + return .unusable( + message: + "golden `\(path)` has CRLF line endings; goldens are LF files, so check them out " + + "with LF (`*.txt text eol=lf` in .gitattributes)") + } + let expected = ScenarioParser.sourceLines(of: goldenText).dropTrailingEmptyRows() + let actual = ScenarioParser.sourceLines(of: text).dropTrailingEmptyRows() + let row = expected.firstDifference(from: actual) ?? min(expected.count, actual.count) + return .drifted( + message: + "shot `\(name)` differs from `\(path)` at row \(row): the golden has " + + "\(expected.count) rows and the screen has \(actual.count). If the new rendering " + + "is intended, rerun with --record and review the golden's diff", + expected: row < expected.count ? expected[row] : "(no row \(row))", + actual: row < actual.count ? actual[row] : "(no row \(row))") + } +} + +extension Array where Element == String { + /// The rows without the empty ones at the end. + func dropTrailingEmptyRows() -> [String] { + var rows = self + while rows.last?.isEmpty == true { + rows.removeLast() + } + return rows + } +} + +extension Array where Element: Equatable { + /// The first index at which `self` and `other` differ, or `nil` when one is a prefix of the + /// other. + func firstDifference(from other: [Element]) -> Int? { + zip(self, other).enumerated().first { $0.element.0 != $0.element.1 }?.offset + } +} diff --git a/Sources/TesseraHarness/Scenarios/Grammar.swift b/Sources/TesseraHarness/Scenarios/Grammar.swift new file mode 100644 index 0000000..5116c3c --- /dev/null +++ b/Sources/TesseraHarness/Scenarios/Grammar.swift @@ -0,0 +1,51 @@ +/// The vocabulary the parser accepts and the forms it names but refuses. +/// +/// `Examples/Scenarios/README.md` is the grammar's single source; a test holds its "Not yet +/// expressible" table to ``notYetExpressible``, so the two cannot drift. +package enum ScenarioGrammar { + /// A form the grammar names that the harness cannot honour yet. + package struct Deferred: Equatable, Sendable { + /// The form as the README's table writes it. + package var form: String + + /// What the form waits for. + package var needs: String + } + + /// Every statement form that parses as "not yet expressible", never as a silent no-op. + package static let notYetExpressible: [Deferred] = [ + Deferred(form: "wait", needs: "the schedulable clock"), + Deferred(form: "settle", needs: "the schedulable clock"), + Deferred(form: "focus", needs: "focus tracking and role and label locators"), + Deferred(form: "mouse", needs: "pointer input and hit testing"), + Deferred(form: "hit", needs: "pointer input and hit testing"), + Deferred(form: "dump", needs: "the projection"), + Deferred(form: "check", needs: "the invariant rules, which read the projection"), + Deferred(form: "expect cell", needs: "style and role assertions on cells"), + Deferred(form: "expect cost", needs: "the per-step cost counters"), + Deferred(form: "expect focused", needs: "focus tracking"), + Deferred(form: "expect wireframe", needs: "catalog conformance"), + Deferred(form: "expect node SEL visible", needs: "the projection"), + Deferred(form: "expect node SEL clipped", needs: "the projection"), + Deferred(form: "expect node SEL count N", needs: "the projection"), + Deferred(form: "shot NAME SEL", needs: "selectors over the projection"), + Deferred(form: "tree SEL", needs: "selectors over the projection"), + Deferred(form: "term glyphs=", needs: "ASCII glyph fallbacks in the views"), + Deferred(form: "term keyboard=", needs: "key routing that depends on the protocol"), + Deferred(form: "term theme=probe", needs: "semantic roles"), + ] + + /// The statements a scenario may use, for the error that names the vocabulary. + static let acceptedStatements = + "app, size, term, resize, key, type, paste, bytes, shot, tree, " + + "expect text, expect screen, expect node" + + /// The refusal for a form that is named but not yet expressible. + static func refusal(_ form: String) -> String { + let needs = notYetExpressible.first { $0.form == form }?.needs ?? "a later release" + return + "`\(form)` is not yet expressible: it needs \(needs). " + + "See the \"Not yet expressible\" table in Examples/Scenarios/README.md; " + + "accepted: \(acceptedStatements)" + } +} diff --git a/Sources/TesseraHarness/Scenarios/Report.swift b/Sources/TesseraHarness/Scenarios/Report.swift index 6b793ee..f99d446 100644 --- a/Sources/TesseraHarness/Scenarios/Report.swift +++ b/Sources/TesseraHarness/Scenarios/Report.swift @@ -1,87 +1,247 @@ import Foundation -/// One failed statement. `parse` failures carry only `message`. -package struct ScenarioFailure: Codable, Equatable, Sendable { - package var line: Int - package var statement: String - package var verb: String - package var message: String - package var expected: String? - package var actual: String? +/// One statement of a scenario that did not hold, or one reason a scenario could not run. +/// +/// `verb` says what kind of failure it is, and so which exit code a command ends with: a +/// statement's own verb (`expect text`, `shot`, `key`, …) or `size` or `repeat` is an +/// expectation that failed (exit 1); `parse` and `usage` are mistakes in the scenario file or +/// the command (exit 2); `harness` is a fault in the run itself (exit 3). +public struct ScenarioFailure: Equatable, Sendable { + /// The failing statement's line in the scenario file, counted from 1, or 0 when the + /// failure belongs to the file or the command rather than to a line. + public let line: Int + + /// The statement's text, or empty when the failure has none. + public let statement: String + + /// The statement's verb, or `parse`, `usage`, `harness`, `size`, or `repeat`. + public let verb: String + + /// What went wrong, written for the person or agent who has to fix it. + public let message: String - /// Screen rows (trailing blanks trimmed, trailing empty rows dropped) at the failure. - package var screen: [String]? // swiftlint:disable:this discouraged_optional_collection + /// The value the statement expected, when it compares one. + package let expected: String? + + /// The value the run produced, when the statement compares one. + package let actual: String? + + /// The screen at the failure, trailing blanks trimmed and trailing empty rows dropped. + package let screen: [String]? // swiftlint:disable:this discouraged_optional_collection + + // swiftlint:disable discouraged_optional_collection + package init( + line: Int, + statement: String, + verb: String, + message: String, + expected: String? = nil, + actual: String? = nil, + screen: [String]? = nil + ) { + self.line = line + self.statement = statement + self.verb = verb + self.message = message + self.expected = expected + self.actual = actual + self.screen = screen + } + // swiftlint:enable discouraged_optional_collection } /// Output of an observation statement (`shot`, `tree`). -package struct ScenarioObservation: Codable, Equatable, Sendable { +package struct ScenarioObservation: Encodable, Equatable, Sendable { package var line: Int package var verb: String package var name: String? package var lines: [String] } -/// The text and the node tree a `shot` statement captures, for a runner to write as files. -struct ShotArtifact: Equatable, Sendable { - var name: String - var text: String - var tree: String +/// A golden file `--record` wrote. +package struct GoldenChange: Encodable, Equatable, Sendable { + /// What `--record` did to a golden. + package enum Change: String, Encodable, Sendable { + case created + case updated + } + + package var path: String + package var change: Change } -/// The single source of the human and JSON forms. -package struct ScenarioReport: Codable, Equatable, Sendable { - package var scenario: String - package var passed: Bool - package var failures: [ScenarioFailure] - package var observations: [ScenarioObservation] +/// What one scenario run found, in the human form and the JSON form that share it. +public struct ScenarioReport: CustomStringConvertible, Equatable, Sendable { + /// The scenario file's path, as the run was given it. + public let scenario: String + + /// Every statement that failed, in order; the run continues after a failed expectation, so + /// one run reports them all. + public let failures: [ScenarioFailure] + + /// What the `shot` and `tree` statements saw, in order. + package let observations: [ScenarioObservation] + + /// The goldens `--record` wrote. + package var recorded: [GoldenChange] + + /// Wall-clock seconds, one per run, when `--timing` asked for them, and empty otherwise. + package var seconds: [Double] + + /// Whether every statement held. + public var passed: Bool { + failures.isEmpty + } + + /// The human form: `PASS` or `FAIL` and the scenario's path, then each failure with the + /// statement, what was expected, what was seen, and the screen. + /// + /// A `parse` failure prints as `FILE:LINE: error: message`, the form editors and CI logs + /// link. + public var description: String { + var lines = ["\(passed ? "PASS" : "FAIL") \(scenario)\(timingText)"] + for change in recorded { + lines.append(" recorded \(change.path) (\(change.change.rawValue))") + } + for failure in failures { + lines += humanLines(for: failure) + } + return lines.joined(separator: "\n") + } + + /// The JSON form: one compact, sorted-key object on one line, with no trailing newline. + public var json: String { + JSONText.string(for: ReportBody(self)) + } + /// The report with everything that varies between two runs of the same scenario removed. + var comparable: Self { + Self(scenario: scenario, failures: failures, observations: observations) + } + + private var timingText: String { + guard !seconds.isEmpty else { + return "" + } + return " (\(seconds.map { String($0) }.joined(separator: ", ")) s)" + } + + package init( + scenario: String, + failures: [ScenarioFailure], + observations: [ScenarioObservation] = [], + recorded: [GoldenChange] = [], + seconds: [Double] = [] + ) { + self.scenario = scenario + self.failures = failures + self.observations = observations + self.recorded = recorded + self.seconds = seconds + } + + /// A report for a scenario that could not be parsed. static func parseFailure(_ error: ScenarioParseError) -> Self { Self( scenario: error.file, - passed: false, failures: [ ScenarioFailure( - line: error.line, - statement: "", - verb: "parse", - message: error.description + line: error.line, statement: error.source, verb: "parse", message: error.message ) - ], - observations: [] + ] ) } - /// Compact, sorted-key JSON on one line. - package func json() -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] - let data = (try? encoder.encode(self)) ?? Data() - // swiftlint:disable:next optional_data_string_conversion - return String(decoding: data, as: UTF8.self) + /// A report for a scenario, path, or command that could not run at all. + static func failure(scenario: String, verb: String, message: String) -> Self { + Self( + scenario: scenario, + failures: [ScenarioFailure(line: 0, statement: "", verb: verb, message: message)] + ) } - package func human() -> String { - var lines = ["\(passed ? "PASS" : "FAIL") \(scenario)"] - for failure in failures { - if failure.verb == "parse" || failure.verb == "usage" { - lines.append(" \(failure.message)") - continue - } - lines.append(" line \(failure.line): \(failure.statement)") - lines.append(" \(failure.message)") - if let expected = failure.expected { - lines.append(" expected: \(expected)") - } - if let actual = failure.actual { - lines.append(" actual: \(actual)") - } - if let screen = failure.screen { - lines.append(" screen:") - for (row, text) in screen.enumerated() { - lines.append(" \(row)| \(text)") - } + private func humanLines(for failure: ScenarioFailure) -> [String] { + if failure.verb == "parse" || failure.verb == "usage" { + let place = failure.line > 0 ? "\(scenario):\(failure.line)" : scenario + return ["\(place): error: \(failure.message)"] + } + var lines = [ + failure.line > 0 + ? " line \(failure.line): \(failure.statement)" : " \(failure.verb)" + ] + lines.append(" \(failure.message)") + if let expected = failure.expected { + lines.append(" expected: \(expected)") + } + if let actual = failure.actual { + lines.append(" actual: \(actual)") + } + if let screen = failure.screen { + lines.append(" screen:") + for (row, text) in screen.enumerated() { + lines.append(" \(row)| \(text)") } } - return lines.joined(separator: "\n") + return lines + } +} + +/// The JSON form of a report, which the public types do not conform to themselves: the fields +/// are promised, the `Encodable` conformance is not. +struct ReportBody: Encodable { + /// One failure's fields; `expected`, `actual`, and `screen` are left out when absent. + struct Failure: Encodable { + var line: Int + var statement: String + var verb: String + var message: String + var expected: String? + var actual: String? + var screen: [String]? // swiftlint:disable:this discouraged_optional_collection + + init(_ failure: ScenarioFailure) { + line = failure.line + statement = failure.statement + verb = failure.verb + message = failure.message + expected = failure.expected + actual = failure.actual + screen = failure.screen + } + } + + private enum CodingKeys: String, CodingKey { + case failures, observations, passed, recorded, scenario, seconds + } + + var report: ScenarioReport + + init(_ report: ScenarioReport) { + self.report = report + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(report.failures.map(Failure.init), forKey: .failures) + try container.encode(report.observations, forKey: .observations) + try container.encode(report.passed, forKey: .passed) + if !report.recorded.isEmpty { + try container.encode(report.recorded, forKey: .recorded) + } + try container.encode(report.scenario, forKey: .scenario) + if !report.seconds.isEmpty { + try container.encode(report.seconds, forKey: .seconds) + } + } +} + +/// Compact, sorted-key JSON, the one encoding every command's output uses. +enum JSONText { + static func string(for 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) } } diff --git a/Sources/TesseraHarness/Scenarios/Runner.swift b/Sources/TesseraHarness/Scenarios/Runner.swift deleted file mode 100644 index 09a0c45..0000000 --- a/Sources/TesseraHarness/Scenarios/Runner.swift +++ /dev/null @@ -1,239 +0,0 @@ -import TesseraCore -import TesseraTerminalCore -import TesseraTerminalInput - -/// 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 { - /// 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, - 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: 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( - _ message: String, expected: String? = nil, actual: String? = nil - ) -> ScenarioFailure { - ScenarioFailure( - line: line.number, - statement: line.source, - verb: line.statement.verb, - message: message, - expected: expected, - actual: actual, - screen: screenForReport(host.screenRows) - ) - } - - 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): - guard await deliver(.resize(TerminalSize(columns: columns, rows: rows))) else { - return - } - - case .key(let key): - guard await deliver(.key(key)) else { - return - } - - case .type(let text): - for character in text { - guard await deliver(.key(Key(code: .character(character)))) else { - return - } - } - - case .shot(let shotName): - let rows = host.screenRows - observations.append( - ScenarioObservation( - line: line.number, verb: "shot", name: shotName, - lines: screenForReport(rows))) - artifacts.append( - ShotArtifact( - name: shotName, - text: rows.joined(separator: "\n") + "\n", - tree: treeLines(host.diagnostics.nodes).joined(separator: "\n") + "\n")) - - case .tree: - observations.append( - ScenarioObservation( - line: line.number, verb: "tree", name: nil, - lines: treeLines(host.diagnostics.nodes))) - - case .expectText(let row, let text): - let rows = host.screenRows - if let row { - guard rows.indices.contains(row) else { - failures.append( - failure( - "row \(row) is outside the \(rows.count)-row screen", - expected: text, actual: nil)) - continue - } - if rows[row] != text { - failures.append( - failure( - "row \(row) text differs", expected: text, actual: rows[row])) - } - } else if !rows.contains(where: { $0.contains(text) }) { - failures.append( - failure( - "no screen row contains the text", expected: text, - actual: "(not found in \(rows.count) rows)")) - } - - case .expectNode(let locator, let frame): - let nodes = host.diagnostics.nodes - switch locator.resolve(in: nodes, screenRows: host.screenRows) { - case .node(let node): - let actual = FrameExpectation( - column: node.frame.origin.column, row: node.frame.origin.row, - columns: node.frame.size.columns, rows: node.frame.size.rows) - if actual != frame { - failures.append( - failure( - "frame of `\(locator.description)` differs", - expected: frame.description, actual: actual.description)) - } - case .none: - failures.append( - failure( - "locator `\(locator.description)` matches no node", - expected: frame.description, - actual: "(no node; types: \(distinctTypes(nodes)))")) - case .ambiguous(let matches): - failures.append( - failure( - "locator `\(locator.description)` matches \(matches.count) nodes; add nth=", - expected: frame.description, - actual: matches.map(\.identity.description).joined(separator: ", "))) - } - } - } - } - - /// Rows with trailing empty rows dropped, so a 500-row probe stays readable. - static func screenForReport(_ rows: [String]) -> [String] { - var rows = rows - while rows.last?.isEmpty == true { - rows.removeLast() - } - return rows - } - - static func distinctTypes(_ nodes: [NodeDiagnostics]) -> String { - var seen: [String] = [] - for node in nodes { - let type = Locator.baseTypeName(node.viewType) - if !seen.contains(type) { - seen.append(type) - } - } - return seen.joined(separator: ", ") - } - - /// One line per node: indentation by depth, identity, base type, frame (and clip if it differs). - static func treeLines(_ nodes: [NodeDiagnostics]) -> [String] { - nodes.map { node in - let identity = node.identity.description - let depth = identity.split(separator: "/").count - 1 - var line = - String(repeating: " ", count: depth) - + "\(identity) \(Locator.baseTypeName(node.viewType)) " - + "frame=\(rectText(node.frame))" - if node.clip != node.frame { - line += " clip=\(rectText(node.clip))" - } - return line - } - } - - private static func rectText(_ rect: Rect) -> String { - "\(rect.origin.column),\(rect.origin.row),\(rect.size.columns)x\(rect.size.rows)" - } -} diff --git a/Sources/TesseraHarness/Scenarios/Scenario.swift b/Sources/TesseraHarness/Scenarios/Scenario.swift deleted file mode 100644 index d02a7c1..0000000 --- a/Sources/TesseraHarness/Scenarios/Scenario.swift +++ /dev/null @@ -1,392 +0,0 @@ -import Foundation -import TesseraTerminalCore -import TesseraTerminalInput - -/// A rectangle written as `x,y,wxh` in `expect node … frame`. -package struct FrameExpectation: Equatable, Sendable { - package var column: Int - package var row: Int - package var columns: Int - package var rows: Int - - var description: String { - "\(column),\(row),\(columns)x\(rows)" - } -} - -/// One executable scenario statement. -package enum Statement: Equatable, Sendable { - case expectNode(Locator, FrameExpectation) - case expectText(row: Int?, text: String) - case key(Key) - case resize(columns: Int, rows: Int) - case shot(name: String) - case tree - case type(String) - - /// The verb as it appears in reports. - package var verb: String { - switch self { - case .expectNode: "expect node" - case .expectText: "expect text" - case .key: "key" - case .resize: "resize" - case .shot: "shot" - case .tree: "tree" - case .type: "type" - } - } -} - -/// A statement with its source position. -package struct ScenarioLine: Equatable, Sendable { - package var number: Int - package var source: String - package var statement: Statement -} - -/// A parsed scenario: a header followed by an ordered list of statements. -package struct Scenario: Equatable, Sendable { - /// The registered app name, from the `app NAME` header. - package var app: String - package var viewport: Viewport - package var lines: [ScenarioLine] -} - -/// A fail-fast parse error that renders as `FILE:LINE: error: message`. -package struct ScenarioParseError: Error, Equatable, Sendable, CustomStringConvertible { - package var file: String - package var line: Int - package var message: String - - package var description: String { - "\(file):\(line): error: \(message)" - } -} - -/// Parses the v0 scenario grammar. -/// -/// ```text -/// # header (before the first body statement) -/// app NAME size WxH | desktop | mobile | mobileScreen -/// # world and input -/// resize WxH key Tab | key Shift+Tab | key Ctrl+C type "hi" -/// # observation -/// shot NAME tree -/// # expectation -/// expect text ["ROW"] "…" expect node SEL frame X,Y,WxH -/// ``` -package enum ScenarioParser { - /// Verbs the plan reserves for later phases; named so the error is specific. - static let deferredVerbs: Set = [ - "bytes", "check", "dump", "focus", "mouse", "paste", "wait", - ] - - static let acceptedVerbs = - "app, size, resize, key, type, shot, tree, expect text, expect node" - - package static func parse( - _ source: String, - file: String, - knownApps: [String] - ) throws -> Scenario { - var app: String? - var viewport = Viewport.desktop - var lines: [ScenarioLine] = [] - var shotNames: Set = [] - var sawApp = false - var sawSize = false - - let rawLines = source.split(separator: "\n", omittingEmptySubsequences: false) - for (offset, rawLine) in rawLines.enumerated() { - let number = offset + 1 - func fail(_ message: String) -> ScenarioParseError { - ScenarioParseError(file: file, line: number, message: message) - } - - let tokens: [String] - do { - tokens = try Tokenizer.tokens(in: String(rawLine)) - } catch let error as Tokenizer.Failure { - throw fail(error.message) - } - guard let verb = tokens.first else { - continue - } - let arguments = Array(tokens.dropFirst()) - let text = String(rawLine).trimmingCharacters(in: .whitespaces) - - switch verb { - case "app": - guard lines.isEmpty else { - throw fail( - "`app` is a header statement and must precede the first body statement") - } - guard !sawApp else { throw fail("duplicate `app` header") } - guard arguments.count == 1 else { throw fail("usage: app NAME") } - guard knownApps.contains(arguments[0]) else { - throw fail( - "unknown app `\(arguments[0])`; accepted: \(knownApps.joined(separator: ", "))" - ) - } - sawApp = true - app = arguments[0] - - case "size": - guard lines.isEmpty else { - throw fail( - "`size` is a header statement; use `resize WxH` after the first body statement" - ) - } - guard !sawSize else { throw fail("duplicate `size` header") } - guard arguments.count == 1 else { - throw fail("usage: size WxH | desktop | mobile | mobileScreen") - } - sawSize = true - switch arguments[0] { - case "desktop": viewport = .desktop - case "mobile": viewport = .mobile - case "mobileScreen": viewport = .mobileScreen - default: - guard let (columns, rows) = parseDimensions(arguments[0]) else { - throw fail( - "bad size `\(arguments[0])`; expected WxH, desktop, mobile, or mobileScreen" - ) - } - viewport = .custom(TerminalSize(columns: columns, rows: rows)) - } - - case "resize": - guard arguments.count == 1, let (columns, rows) = parseDimensions(arguments[0]) - else { - throw fail("usage: resize WxH (both at least 1)") - } - lines.append( - ScenarioLine( - number: number, source: text, statement: .resize(columns: columns, rows: rows) - )) - - case "key": - guard arguments.count == 1 else { - throw fail("usage: key NAME (e.g. key Shift+Tab)") - } - do { - let key = try KeyName.parse(arguments[0]) - lines.append(ScenarioLine(number: number, source: text, statement: .key(key))) - } catch let error as KeyName.Failure { - throw fail(error.message) - } - - case "type": - guard arguments.count == 1 else { throw fail("usage: type \"text\"") } - lines.append( - ScenarioLine(number: number, source: text, statement: .type(arguments[0]))) - - case "shot": - guard arguments.count == 1, isShotName(arguments[0]) else { - throw fail("usage: shot NAME (letters, digits, `.`, `_`, `-`)") - } - guard shotNames.insert(arguments[0]).inserted else { - throw fail("duplicate shot name `\(arguments[0])`") - } - lines.append( - ScenarioLine(number: number, source: text, statement: .shot(name: arguments[0])) - ) - - case "tree": - guard arguments.isEmpty else { - throw fail("`tree` takes no selector in v0") - } - lines.append(ScenarioLine(number: number, source: text, statement: .tree)) - - case "expect": - let statement = try parseExpect(arguments, fail: fail) - lines.append(ScenarioLine(number: number, source: text, statement: statement)) - - default: - if deferredVerbs.contains(verb) { - throw fail("`\(verb)` is not yet expressible in v0; accepted: \(acceptedVerbs)") - } - throw fail("unknown statement `\(verb)`; accepted: \(acceptedVerbs)") - } - } - - 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) - } - - private static func parseExpect( - _ arguments: [String], - fail: (String) -> ScenarioParseError - ) throws -> Statement { - guard let kind = arguments.first else { - throw fail("usage: expect text … | expect node SEL frame X,Y,WxH") - } - let rest = Array(arguments.dropFirst()) - switch kind { - case "text": - switch rest.count { - case 1: - return .expectText(row: nil, text: rest[0]) - case 2: - guard let row = Int(rest[0]), row >= 0 else { - throw fail("expect text ROW must be a non-negative integer, got `\(rest[0])`") - } - return .expectText(row: row, text: rest[1]) - default: - throw fail("usage: expect text [ROW] \"…\"") - } - - case "node": - guard let frameIndex = rest.firstIndex(of: "frame") else { - throw fail( - "expect node needs `frame X,Y,WxH`; visible|clipped|count are not yet expressible" - ) - } - let selectorTokens = Array(rest[.. (Int, Int)? { - let parts = token.split(separator: "x", omittingEmptySubsequences: false) - guard parts.count == 2, let columns = Int(parts[0]), let rows = Int(parts[1]), - columns >= 1, rows >= 1 - else { - return nil - } - return (columns, rows) - } - - /// Accepts `X,Y,WxH` (one token) or the plan's `X,Y WxH` (two tokens). - static func parseFrame(_ tokens: [String]) -> FrameExpectation? { - let joined = tokens.joined(separator: ",") - let parts = joined.split(separator: ",", omittingEmptySubsequences: false) - guard parts.count == 3, - let column = Int(parts[0]), let row = Int(parts[1]), - let size = parseDimensionsAllowingZero(String(parts[2])) - else { - return nil - } - return FrameExpectation( - column: column, row: row, columns: size.0, rows: size.1) - } - - private static func parseDimensionsAllowingZero(_ token: String) -> (Int, Int)? { - let parts = token.split(separator: "x", omittingEmptySubsequences: false) - guard parts.count == 2, let columns = Int(parts[0]), let rows = Int(parts[1]), - columns >= 0, rows >= 0 - else { - return nil - } - return (columns, rows) - } - - private static func isShotName(_ name: String) -> Bool { - !name.isEmpty - && name.allSatisfy { - $0.isASCII && ($0.isLetter || $0.isNumber || "._-".contains($0)) - } - } -} - -/// Shell-style tokenizer: whitespace separated, `"…"` with escapes, `'…'` literal, -/// `#` starts a comment at a token boundary. -enum Tokenizer { - struct Failure: Error { - var message: String - } - - static func tokens(in line: String) throws -> [String] { - var tokens: [String] = [] - var current = "" - var inToken = false - var iterator = line.makeIterator() - var pending = iterator.next() - - func flush() { - if inToken { - tokens.append(current) - current = "" - inToken = false - } - } - - while let character = pending { - pending = iterator.next() - switch character { - case " ", "\t", "\r": - flush() - case "#" where !inToken: - pending = nil - case "\"": - inToken = true - var closed = false - while let next = pending { - pending = iterator.next() - if next == "\"" { - closed = true - break - } - if next == "\\" { - guard let escaped = pending else { - throw Failure(message: "dangling backslash in quoted string") - } - pending = iterator.next() - switch escaped { - case "n": current.append("\n") - case "t": current.append("\t") - case "\"", "\\": current.append(escaped) - default: - throw Failure( - message: "unknown escape `\\\(escaped)`; accepted: \\n \\t \\\" \\\\") - } - } else { - current.append(next) - } - } - guard closed else { throw Failure(message: "unterminated double quote") } - case "'": - inToken = true - var closed = false - while let next = pending { - pending = iterator.next() - if next == "'" { - closed = true - break - } - current.append(next) - } - guard closed else { throw Failure(message: "unterminated single quote") } - default: - inToken = true - current.append(character) - } - } - flush() - return tokens - } -} diff --git a/Sources/TesseraHarness/Scenarios/ScenarioFiles.swift b/Sources/TesseraHarness/Scenarios/ScenarioFiles.swift new file mode 100644 index 0000000..55351cf --- /dev/null +++ b/Sources/TesseraHarness/Scenarios/ScenarioFiles.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Finds the scenario files a `run` was given. +/// +/// A path is a scenario file, which ends in `.scn`, or a directory, which contributes every +/// `.scn` file under it. Directories are walked in sorted order and entries that start with a +/// `.` are skipped, so the order of a run, and so its output, is the same on every machine. +enum ScenarioFiles { + /// What a path turned into. + enum Item { + /// A path that cannot be run, already reported. + case report(ScenarioReport) + + /// A scenario file to run. + case scenario(path: String) + } + + /// The extension every scenario file has. + static let fileExtension = ".scn" + + static func discover(_ paths: [String]) -> [Item] { + paths.flatMap { discover($0) } + } + + private static func discover(_ given: String) -> [Item] { + let path = trimmed(given) + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { + return [ + .report( + .failure(scenario: given, verb: "usage", message: "no such file or directory")) + ] + } + if isDirectory.boolValue { + let files = scenarioFiles(under: path) + guard !files.isEmpty else { + return [ + .report( + .failure( + scenario: given, verb: "usage", + message: "no scenario files (*\(fileExtension)) under this directory")) + ] + } + return files.map { .scenario(path: $0) } + } + guard path.hasSuffix(fileExtension) else { + return [ + .report( + .failure( + scenario: given, verb: "usage", + message: "scenario files end in `\(fileExtension)`")) + ] + } + return [.scenario(path: path)] + } + + /// Every `.scn` file under `directory`, depth first, entries sorted by name. + private static func scenarioFiles(under directory: String) -> [String] { + let names = (try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? [] + var files: [String] = [] + for name in names.sorted() where !name.hasPrefix(".") { + let path = "\(directory)/\(name)" + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { + continue + } + if isDirectory.boolValue { + files += scenarioFiles(under: path) + } else if name.hasSuffix(fileExtension) { + files.append(path) + } + } + return files + } + + /// `path` without a trailing separator, so joined paths have exactly one. + private static func trimmed(_ path: String) -> String { + var path = path + while path.count > 1, path.hasSuffix("/") { + path.removeLast() + } + return path + } +} diff --git a/Sources/TesseraHarness/Scenarios/ScenarioParser.swift b/Sources/TesseraHarness/Scenarios/ScenarioParser.swift new file mode 100644 index 0000000..8b311fa --- /dev/null +++ b/Sources/TesseraHarness/Scenarios/ScenarioParser.swift @@ -0,0 +1,404 @@ +import Foundation +import TesseraTerminalCore +import TesseraTerminalInput + +/// Parses the scenario grammar in `Examples/Scenarios/README.md`. +/// +/// The parser is header-binding and fail-fast: `app`, `size`, and `term` must precede the +/// first body statement, and the first line it cannot accept ends the parse with a +/// ``ScenarioParseError``. An unknown statement is an error, so a scenario written for a +/// newer harness fails loudly on an older one instead of running with a statement ignored. +/// Lines end at `\n`, and a `\r` before it is whitespace, so a CRLF checkout parses as an LF +/// one does. +package enum ScenarioParser { + package static func parse( + _ source: String, + file: String, + knownApps: [String] + ) throws -> Scenario { + var parse = Parse(file: file, knownApps: knownApps, lines: sourceLines(of: source)) + return try parse.run() + } + + /// The lines of `source`, split at `\n` alone: Swift reads `"\r\n"` as one character, so + /// splitting on characters would not see a CRLF line end. + static func sourceLines(of source: String) -> [String] { + source.unicodeScalars.split( + separator: "\n", omittingEmptySubsequences: false + ).map { String($0) } + } + + static func parseDimensions(_ token: String, allowingZero: Bool = false) -> (Int, Int)? + { + let parts = token.split(separator: "x", omittingEmptySubsequences: false) + let minimum = allowingZero ? 0 : 1 + guard parts.count == 2, let columns = Int(parts[0]), let rows = Int(parts[1]), + columns >= minimum, rows >= minimum + else { + return nil + } + return (columns, rows) + } + + /// The viewport a `size` header or a command's size argument spells, or `nil`. + static func viewport(spelled token: String) -> Viewport? { + switch token { + case "desktop": .desktop + case "mobile": .mobile + case "mobileScreen": .mobileScreen + default: + parseDimensions(token).map { .custom(TerminalSize(columns: $0.0, rows: $0.1)) } + } + } + + /// Accepts `X,Y,WxH` (one token) or `X,Y WxH` (two tokens). + static func parseFrame(_ tokens: [String]) -> FrameExpectation? { + let parts = tokens.joined(separator: ",").split( + separator: ",", omittingEmptySubsequences: false) + guard parts.count == 3, + let column = Int(parts[0]), let row = Int(parts[1]), + let size = parseDimensions(String(parts[2]), allowingZero: true) + else { + return nil + } + return FrameExpectation(column: column, row: row, columns: size.0, rows: size.1) + } + + static func isShotName(_ name: String) -> Bool { + !name.isEmpty + && name.allSatisfy { + $0.isASCII && ($0.isLetter || $0.isNumber || "._-".contains($0)) + } + } +} + +/// One parse in progress: the header as it binds, then the body. +private struct Parse { + let file: String + let knownApps: [String] + let rawLines: [String] + + private var app: String? + private var appLine: SourceLine? + private var viewport = Viewport.desktop + private var sizeLine: SourceLine? + private var sawTerm = false + private var profile = TerminalProfile() + private var body: [ScenarioLine] = [] + private var shotNames: Set = [] + + init(file: String, knownApps: [String], lines: [String]) { + self.file = file + self.knownApps = knownApps + rawLines = lines + } + + mutating func run() throws -> Scenario { + var offset = 0 + while offset < rawLines.count { + offset += try parseLine(at: offset) + } + guard let app, let appLine 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, appLine: appLine, viewport: viewport, sizeLine: sizeLine, + terminal: profile, lines: body) + } + + /// Parses the statement at `offset` and returns how many lines it used. + private mutating func parseLine(at offset: Int) throws -> Int { + let number = offset + 1 + let raw = rawLines[offset] + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let file = file + func fail(_ message: String) -> ScenarioParseError { + ScenarioParseError(file: file, line: number, source: text, message: message) + } + + let tokens: [String] + do { + tokens = try Tokenizer.tokens(in: raw) + } catch let error as Tokenizer.Failure { + throw fail(error.message) + } + guard let verb = tokens.first else { + return 1 + } + let arguments = Array(tokens.dropFirst()) + let source = SourceLine(number: number, text: text) + + switch verb { + case "app", "size", "term": + try parseHeader(verb, arguments, source: source, fail: fail) + case "expect" where arguments.first == "screen": + return try parseExpectScreen(arguments, at: offset, fail: fail) + default: + let statement = try parseBody(verb, arguments, fail: fail) + body.append(ScenarioLine(number: number, source: text, statement: statement)) + } + return 1 + } + + // MARK: Header + + private mutating func parseHeader( + _ verb: String, + _ arguments: [String], + source: SourceLine, + fail: (String) -> ScenarioParseError + ) throws { + guard body.isEmpty else { + throw fail( + verb == "size" + ? "`size` is a header statement; use `resize WxH` after the first body statement" + : "`\(verb)` is a header statement and must precede the first body statement") + } + switch verb { + case "app": + guard app == nil else { throw fail("duplicate `app` header") } + guard arguments.count == 1 else { throw fail("usage: app NAME") } + guard knownApps.contains(arguments[0]) else { + throw fail( + "unknown app `\(arguments[0])`; accepted: \(knownApps.joined(separator: ", "))") + } + app = arguments[0] + appLine = source + + case "size": + guard sizeLine == nil else { throw fail("duplicate `size` header") } + guard arguments.count == 1 else { + throw fail("usage: size WxH | desktop | mobile | mobileScreen") + } + guard let spelled = ScenarioParser.viewport(spelled: arguments[0]) else { + throw fail( + "bad size `\(arguments[0])`; expected WxH, desktop, mobile, or mobileScreen") + } + sizeLine = source + viewport = spelled + + default: + guard !sawTerm else { throw fail("duplicate `term` header") } + sawTerm = true + profile = try parseTerm(arguments, fail: fail) + } + } + + private func parseTerm( + _ options: [String], fail: (String) -> ScenarioParseError + ) throws -> TerminalProfile { + var profile = TerminalProfile() + var seen: Set = [] + for option in options { + guard let equals = option.firstIndex(of: "=") else { + throw fail( + "term option `\(option)` is not key=value; accepted keys: colors, theme") + } + let key = String(option[.. ScenarioParseError + ) throws -> Statement { + switch verb { + case "resize": + guard arguments.count == 1, + let (columns, rows) = ScenarioParser.parseDimensions(arguments[0]) + else { + throw fail("usage: resize WxH (both at least 1)") + } + return .resize(columns: columns, rows: rows) + + case "key": + guard arguments.count == 1 else { + throw fail("usage: key NAME (e.g. key Shift+Tab)") + } + do { + return .key(try KeyName.parse(arguments[0])) + } catch let error as KeyName.Failure { + throw fail(error.message) + } + + case "type": + guard arguments.count == 1 else { throw fail("usage: type \"text\"") } + return .type(arguments[0]) + + case "paste": + guard arguments.count == 1 else { throw fail("usage: paste \"text\"") } + return .paste(arguments[0]) + + case "bytes": + guard arguments.count == 1 else { throw fail(#"usage: bytes "\x1b[A""#) } + return .bytes(Array(arguments[0].utf8)) + + case "shot": + guard arguments.count <= 1 else { + throw fail(ScenarioGrammar.refusal("shot NAME SEL")) + } + guard arguments.count == 1, ScenarioParser.isShotName(arguments[0]) else { + throw fail("usage: shot NAME (letters, digits, `.`, `_`, `-`)") + } + guard shotNames.insert(arguments[0]).inserted else { + throw fail("duplicate shot name `\(arguments[0])`") + } + return .shot(name: arguments[0]) + + case "tree": + guard arguments.isEmpty else { + throw fail(ScenarioGrammar.refusal("tree SEL")) + } + return .tree + + case "expect": + return try parseExpect(arguments, fail: fail) + + default: + if ScenarioGrammar.notYetExpressible.contains(where: { $0.form == verb }) { + throw fail(ScenarioGrammar.refusal(verb)) + } + throw fail( + "unknown statement `\(verb)`; accepted: \(ScenarioGrammar.acceptedStatements)") + } + } + + private func parseExpect( + _ arguments: [String], + fail: (String) -> ScenarioParseError + ) throws -> Statement { + guard let kind = arguments.first else { + throw fail( + "usage: expect text … | expect screen <= 0 else { + throw fail("expect text ROW must be a non-negative integer, got `\(rest[0])`") + } + return .expectText(row: row, text: rest[1]) + default: + throw fail("usage: expect text [ROW] \"…\"") + } + + case "node": + return try parseExpectNode(rest, fail: fail) + + default: + if ScenarioGrammar.notYetExpressible.contains(where: { $0.form == "expect \(kind)" } + ) { + throw fail(ScenarioGrammar.refusal("expect \(kind)")) + } + throw fail("unknown expectation `\(kind)`; accepted: text, screen, node") + } + } + + private func parseExpectNode( + _ rest: [String], + fail: (String) -> ScenarioParseError + ) throws -> Statement { + guard let frameIndex = rest.firstIndex(of: "frame") else { + for kind in ["visible", "clipped", "count"] where rest.contains(kind) { + throw fail( + ScenarioGrammar.refusal( + "expect node SEL \(kind)\(kind == "count" ? " N" : "")")) + } + throw fail("expect node needs `frame X,Y,WxH`") + } + let selectorTokens = Array(rest[.. ScenarioParseError + ) throws -> Int { + guard arguments.count == 2, arguments[1].hasPrefix("<<"), arguments[1].count > 2 + else { + throw fail( + "usage: expect screen < ScenarioReport { + func setupFailure( + _ line: SourceLine, verb: String, message: String + ) -> ScenarioReport { + ScenarioReport( + scenario: name, + failures: [ + ScenarioFailure( + line: line.number, statement: line.text, verb: verb, message: message) + ] + ) + } + + let entry: ApplicationRegistry.Entry + do { + entry = try registry.entry(named: scenario.app) + } catch { + return setupFailure(scenario.appLine, verb: "harness", message: error.description) + } + + let outcome = Outcome() + do { + try await entry.headless(scenario.viewport, scenario.terminal) { host in + await play(scenario, on: host, goldens: goldens, into: outcome) + } + } catch let error as HostError { + return setupFailure( + scenario.sizeLine ?? scenario.appLine, verb: error.verb, message: error.message) + } catch { + return setupFailure(scenario.appLine, verb: "harness", message: "\(error)") + } + return ScenarioReport( + scenario: name, + failures: outcome.failures, + observations: outcome.observations, + recorded: outcome.recorded + ) + } + + /// The screen and node tree of the app registered as `name`, at `viewport`, with no input. + static func picture( + of name: String, + at viewport: Viewport, + registry: ApplicationRegistry + ) async throws -> Picture { + final class Box { + var picture: Picture? + } + let box = Box() + try await registry.entry(named: name).headless(viewport, TerminalProfile()) { host in + box.picture = Picture( + size: host.size, + rows: host.screenRows.dropTrailingEmptyRows(), + tree: treeLines(host.diagnostics.nodes)) + } + guard let picture = box.picture else { + throw HostError(verb: "harness", message: "the application never started") + } + return picture + } + + /// Plays every statement against `host`, stopping early only when the host fails. + private static func play( + _ scenario: Scenario, + on host: any ScenarioHost, + goldens: GoldenStore?, + into outcome: Outcome + ) async { + for line in scenario.lines { + func failure( + _ message: String, verb: String? = nil, expected: String? = nil, + actual: String? = nil, showsScreen: Bool = true + ) -> ScenarioFailure { + ScenarioFailure( + line: line.number, + statement: line.source, + verb: verb ?? line.statement.verb, + message: message, + expected: expected, + actual: actual, + screen: showsScreen ? screenForReport(host.screenRows) : nil + ) + } + + func deliver(_ events: [InputEvent]) async -> Bool { + for event in events { + do { + try await host.send(event) + } catch { + outcome.failures.append( + failure("the host could not deliver the event: \(error)", verb: "harness")) + return false + } + } + return true + } + + switch line.statement { + case .resize(let columns, let rows): + guard await deliver([.resize(TerminalSize(columns: columns, rows: rows))]) else { + return + } + + case .key(let key): + guard await deliver([.key(key)]) else { + return + } + + case .type(let text): + guard await deliver(text.map { .key(Key(code: .character($0))) }) else { + return + } + + case .paste(let text): + guard await deliver([.paste(text)]) else { + return + } + + case .bytes(let bytes): + var parser = InputParser() + let events = parser.feed(contentsOf: bytes) + parser.flush() + guard await deliver(events) else { + return + } + + case .shot(let shotName): + let rows = host.screenRows + outcome.observations.append( + ScenarioObservation( + line: line.number, verb: "shot", name: shotName, + lines: screenForReport(rows))) + guard let goldens else { + continue + } + switch goldens.check(shot: shotName, rows: rows) { + case .matches: + break + case .recorded(let change): + outcome.recorded.append(change) + case .drifted(let message, let expected, let actual): + outcome.failures.append(failure(message, expected: expected, actual: actual)) + case .unusable(let message): + outcome.failures.append(failure(message, showsScreen: false)) + case .failed(let message): + outcome.failures.append(failure(message, verb: "harness")) + } + + case .tree: + outcome.observations.append( + ScenarioObservation( + line: line.number, verb: "tree", name: nil, + lines: treeLines(host.diagnostics.nodes))) + + case .expectText(let row, let text): + if let message = expectText(row: row, text: text, rows: host.screenRows) { + outcome.failures.append( + failure(message.text, expected: text, actual: message.actual)) + } + + case .expectScreen(let expectedRows): + if let difference = screenDifference( + expected: expectedRows, rows: host.screenRows) + { + outcome.failures.append( + failure( + difference.message, expected: difference.expected, actual: difference.actual + )) + } + + case .expectNode(let locator, let frame): + if let difference = nodeDifference( + locator: locator, frame: frame, host: host) + { + outcome.failures.append( + failure( + difference.message, expected: frame.description, actual: difference.actual)) + } + } + } + } + + // MARK: Expectations + + private static func expectText(row: Int?, text: String, rows: [String]) + -> (text: String, actual: String?)? + { + if let row { + guard rows.indices.contains(row) else { + return ("row \(row) is outside the \(rows.count)-row screen", nil) + } + return rows[row] == text ? nil : ("row \(row) text differs", rows[row]) + } + return rows.contains { $0.contains(text) } + ? nil : ("no screen row contains the text", "(not found in \(rows.count) rows)") + } + + /// The first row where `rows` is not `expected`, once both lose their trailing spaces and + /// trailing empty rows. + private static func screenDifference(expected: [String], rows: [String]) + -> (message: String, expected: String, actual: String)? + { + func trimmed(_ rows: [String]) -> [String] { + rows.map { row in + var row = row + while row.last == " " { + row.removeLast() + } + return row + }.dropTrailingEmptyRows() + } + let wanted = trimmed(expected) + let actual = trimmed(rows) + guard wanted != actual else { + return nil + } + let row = wanted.firstDifference(from: actual) ?? min(wanted.count, actual.count) + return ( + "screen differs at row \(row): expected \(wanted.count) rows, the screen has " + + "\(actual.count)", + row < wanted.count ? wanted[row] : "(no row \(row))", + row < actual.count ? actual[row] : "(no row \(row))" + ) + } + + private static func nodeDifference( + locator: Locator, frame: FrameExpectation, host: any ScenarioHost + ) -> (message: String, actual: String)? { + let nodes = host.diagnostics.nodes + switch locator.resolve(in: nodes, screenRows: host.screenRows) { + case .node(let node): + let actual = FrameExpectation( + column: node.frame.origin.column, row: node.frame.origin.row, + columns: node.frame.size.columns, rows: node.frame.size.rows) + return actual == frame + ? nil : ("frame of `\(locator.description)` differs", actual.description) + case .none: + return ( + "locator `\(locator.description)` matches no node", + "(no node; types: \(distinctTypes(nodes)))" + ) + case .ambiguous(let matches): + return ( + "locator `\(locator.description)` matches \(matches.count) nodes; add nth=", + matches.map(\.identity.description).joined(separator: ", ") + ) + } + } + + // MARK: Output + + /// Rows with trailing empty rows dropped, so a 500-row probe stays readable. + static func screenForReport(_ rows: [String]) -> [String] { + rows.dropTrailingEmptyRows() + } + + static func distinctTypes(_ nodes: [NodeDiagnostics]) -> String { + var seen: [String] = [] + for node in nodes { + let type = Locator.baseTypeName(node.viewType) + if !seen.contains(type) { + seen.append(type) + } + } + return seen.joined(separator: ", ") + } + + /// One line per node: indentation by depth, identity, base type, frame (and clip if it differs). + static func treeLines(_ nodes: [NodeDiagnostics]) -> [String] { + nodes.map { node in + let identity = node.identity.description + let depth = identity.split(separator: "/").count - 1 + var line = + String(repeating: " ", count: depth) + + "\(identity) \(Locator.baseTypeName(node.viewType)) " + + "frame=\(rectText(node.frame))" + if node.clip != node.frame { + line += " clip=\(rectText(node.clip))" + } + return line + } + } + + private static func rectText(_ rect: Rect) -> String { + "\(rect.origin.column),\(rect.origin.row),\(rect.size.columns)x\(rect.size.rows)" + } +} diff --git a/Sources/TesseraHarness/Scenarios/ScenarioRunner.swift b/Sources/TesseraHarness/Scenarios/ScenarioRunner.swift new file mode 100644 index 0000000..06f2f09 --- /dev/null +++ b/Sources/TesseraHarness/Scenarios/ScenarioRunner.swift @@ -0,0 +1,177 @@ +import Foundation + +/// Runs scenario files against the applications in an ``ApplicationRegistry``. +/// +/// A scenario is a text file; the grammar is `Examples/Scenarios/README.md`. The runner is the +/// Swift entry point to what `tessera run` does on the command line, so an app's tests and its +/// command line run the same scenarios the same way: +/// +/// ```swift +/// @Test @MainActor +/// func `the scenarios pass`() async { +/// var registry = ApplicationRegistry() +/// registry.register("browser", viewports: [.desktop]) { Browser() } +/// let reports = await ScenarioRunner(registry: registry).run("Scenarios") +/// #expect(reports.filter { !$0.passed }.isEmpty, "\(reports)") +/// } +/// ``` +/// +/// Each scenario starts a fresh application and a fresh virtual terminal, so scenarios do not +/// share state. A `shot` statement is checked against its golden file, which lives in a +/// directory named like the scenario file, and a golden that differs or is missing fails the +/// statement unless the runner records. +public struct ScenarioRunner { + private let registry: ApplicationRegistry + private let record: Bool + + /// How many times each scenario runs; a run that differs from the first is a failure. + package var repetitions = 1 + + /// Whether each report carries the wall-clock time of its runs. + package var measuresTime = false + + /// Creates a runner over `registry`. + /// + /// - Parameters: + /// - registry: The applications scenarios select with their `app` header. + /// - record: Whether to write the goldens that `shot` statements compare against, instead + /// of failing when one differs or is missing. Recording accepts whatever the run + /// produced, so review the files it writes; never record to make a failing run pass. + public init(registry: ApplicationRegistry, record: Bool = false) { + self.registry = registry + self.record = record + } + + /// What changed between two runs of one scenario, for the `repeat` failure. + private static func divergence(of run: ScenarioReport, from first: ScenarioReport) + -> String + { + if run.failures.count != first.failures.count { + return + "it has \(run.failures.count) failures where run 1 has \(first.failures.count)" + } + if let index = first.failures.firstDifference(from: run.failures) { + return "failure \(index + 1), at line \(run.failures[index].line), differs" + } + if run.observations.count != first.observations.count { + return + "it saw \(run.observations.count) observations where run 1 saw " + + "\(first.observations.count)" + } + if let index = first.observations.firstDifference(from: run.observations) { + return "observation \(index + 1), at line \(run.observations[index].line), differs" + } + return "the reports differ" + } + + /// A duration in seconds, to the millisecond. + private static func seconds(_ duration: Duration) -> Double { + let components = duration.components + let seconds = Double(components.seconds) + Double(components.attoseconds) / 1e18 + return (seconds * 1_000).rounded() / 1_000 + } + + /// Runs the scenario files at `paths` and returns one report per scenario. + /// + /// A path is a `.scn` file, or a directory whose `.scn` files, at any depth, run in sorted + /// order. A scenario that cannot be parsed reports a `parse` failure instead of running, and + /// a path that is not a scenario or does not exist reports a `usage` failure, so the result + /// holds exactly one report for each scenario and each bad path, in order. + /// + /// - Parameter paths: Scenario files and directories of them. + /// - Returns: One report per scenario, in the order the scenarios ran. + @MainActor + public func run(_ paths: String...) async -> [ScenarioReport] { + await run(paths: paths) + } + + /// The same as ``run(_:)``, for a path list built at run time. + @MainActor + package func run(paths: [String]) async -> [ScenarioReport] { + let known: [String] + let registryFailure: String? + do { + known = try registry.names() + registryFailure = nil + } catch { + known = [] + registryFailure = error.description + } + + var reports: [ScenarioReport] = [] + for item in ScenarioFiles.discover(paths) { + switch item { + case .report(let report): + reports.append(report) + case .scenario(let path): + if let registryFailure { + reports.append( + .failure(scenario: path, verb: "harness", message: registryFailure)) + } else { + reports.append(await runFile(at: path, knownApps: known)) + } + } + } + return reports + } + + @MainActor + private func runFile(at path: String, knownApps: [String]) async -> ScenarioReport { + guard let data = FileManager.default.contents(atPath: path) else { + return .failure(scenario: path, verb: "harness", message: "cannot read the file") + } + guard var source = String(validating: data, as: UTF8.self) else { + return .failure( + scenario: path, verb: "usage", message: "the file is not UTF-8 text") + } + if source.unicodeScalars.first == "\u{FEFF}" { + source.unicodeScalars.removeFirst() + } + let scenario: Scenario + do { + scenario = try ScenarioParser.parse(source, file: path, knownApps: knownApps) + } catch let error as ScenarioParseError { + return .parseFailure(error) + } catch { + return .failure(scenario: path, verb: "harness", message: "\(error)") + } + + var reports: [ScenarioReport] = [] + var seconds: [Double] = [] + let clock = ContinuousClock() + for run in 0.. ScenarioReport + { + let first = reports[0] + var failures = first.failures + for (offset, report) in reports.enumerated().dropFirst() + where report.comparable != first.comparable { + failures.append( + ScenarioFailure( + line: 0, statement: "--repeat \(reports.count)", verb: "repeat", + message: + "run \(offset + 1) of \(reports.count) differs from run 1: " + + Self.divergence(of: report, from: first))) + } + return ScenarioReport( + scenario: first.scenario, + failures: failures, + observations: first.observations, + recorded: first.recorded, + seconds: measuresTime ? seconds : [] + ) + } +} diff --git a/Sources/TesseraHarness/Scenarios/Statement.swift b/Sources/TesseraHarness/Scenarios/Statement.swift new file mode 100644 index 0000000..4b77727 --- /dev/null +++ b/Sources/TesseraHarness/Scenarios/Statement.swift @@ -0,0 +1,115 @@ +import TesseraTerminalCore +import TesseraTerminalInput + +/// A rectangle written as `x,y,wxh` in `expect node … frame`. +package struct FrameExpectation: Equatable, Sendable { + package var column: Int + package var row: Int + package var columns: Int + package var rows: Int + + var description: String { + "\(column),\(row),\(columns)x\(rows)" + } +} + +/// One executable scenario statement. +package enum Statement: Equatable, Sendable { + case bytes([UInt8]) + case expectNode(Locator, FrameExpectation) + case expectScreen(rows: [String]) + case expectText(row: Int?, text: String) + case key(Key) + case paste(String) + case resize(columns: Int, rows: Int) + case shot(name: String) + case tree + case type(String) + + /// The verb as it appears in reports. + package var verb: String { + switch self { + case .bytes: "bytes" + case .expectNode: "expect node" + case .expectScreen: "expect screen" + case .expectText: "expect text" + case .key: "key" + case .paste: "paste" + case .resize: "resize" + case .shot: "shot" + case .tree: "tree" + case .type: "type" + } + } +} + +/// A line of a scenario file: its number (from 1) and its text, without the line ending. +package struct SourceLine: Equatable, Sendable { + package var number: Int + package var text: String +} + +/// A statement with its source position. +package struct ScenarioLine: Equatable, Sendable { + package var number: Int + package var source: String + package var statement: Statement +} + +/// A parsed scenario: a header followed by an ordered list of statements. +package struct Scenario: Equatable, Sendable { + /// The registered app name, from the `app NAME` header. + package var app: String + + /// The `app` header's line, where a failure to set the app up is reported. + package var appLine: SourceLine + + /// The size the app starts at, from the `size` header. + package var viewport: Viewport + + /// The `size` header's line, if there is one; a size the app cannot take is reported there. + package var sizeLine: SourceLine? + + /// The terminal the app runs on, from the `term` header. + package var terminal: TerminalProfile + + /// The body, in order. + package var lines: [ScenarioLine] + + package init( + app: String, + appLine: SourceLine = SourceLine(number: 1, text: ""), + viewport: Viewport = .desktop, + sizeLine: SourceLine? = nil, + terminal: TerminalProfile = TerminalProfile(), + lines: [ScenarioLine] = [] + ) { + self.app = app + self.appLine = appLine + self.viewport = viewport + self.sizeLine = sizeLine + self.terminal = terminal + self.lines = lines + } +} + +/// A fail-fast parse error that renders as `FILE:LINE: error: message`. +package struct ScenarioParseError: Error, Equatable, Sendable, CustomStringConvertible { + package var file: String + package var line: Int + + /// The offending line's text, empty when the error is about the file as a whole. + package var source: String + package var message: String + + package var description: String { + "\(file):\(line): error: \(message)" + } + + package init(file: String, line: Int, source: String = "", message: String) { + self.file = file + self.line = line + self.source = source + self.message = message + } +} diff --git a/Sources/TesseraHarness/Scenarios/Tokenizer.swift b/Sources/TesseraHarness/Scenarios/Tokenizer.swift new file mode 100644 index 0000000..2d9d727 --- /dev/null +++ b/Sources/TesseraHarness/Scenarios/Tokenizer.swift @@ -0,0 +1,100 @@ +/// Shell-style tokenizer: whitespace separated, `"…"` with escapes, `'…'` literal, +/// `#` starts a comment at a token boundary. +/// +/// A carriage return is whitespace, so a line read from a CRLF file tokenizes as it would +/// from an LF file. +enum Tokenizer { + struct Failure: Error { + var message: String + } + + /// The escapes a double-quoted string accepts, for the error that names them. + static let escapes = #"\n \t \" \\ \xHH"# + + static func tokens(in line: String) throws -> [String] { + var tokens: [String] = [] + var current = "" + var inToken = false + var iterator = line.makeIterator() + var pending = iterator.next() + + func flush() { + if inToken { + tokens.append(current) + current = "" + inToken = false + } + } + + while let character = pending { + pending = iterator.next() + switch character { + case " ", "\t", "\r": + flush() + case "#" where !inToken: + pending = nil + case "\"": + inToken = true + var closed = false + while let next = pending { + pending = iterator.next() + if next == "\"" { + closed = true + break + } + if next == "\\" { + guard let escaped = pending else { + throw Failure(message: "dangling backslash in quoted string") + } + pending = iterator.next() + switch escaped { + case "n": current.append("\n") + case "t": current.append("\t") + case "\"", "\\": current.append(escaped) + case "x": + guard let high = pending?.hexDigitValue else { + throw Failure(message: "`\\x` needs two hex digits, as in `\\x1b`") + } + pending = iterator.next() + guard let low = pending?.hexDigitValue else { + throw Failure(message: "`\\x` needs two hex digits, as in `\\x1b`") + } + pending = iterator.next() + let value = high * 16 + low + guard value < 0x80 else { + throw Failure( + message: + "`\\x` escapes are ASCII (00 to 7f); write a non-ASCII character itself" + ) + } + current.append(Character(Unicode.Scalar(UInt8(value)))) + default: + throw Failure( + message: "unknown escape `\\\(escaped)`; accepted: \(escapes)") + } + } else { + current.append(next) + } + } + guard closed else { throw Failure(message: "unterminated double quote") } + case "'": + inToken = true + var closed = false + while let next = pending { + pending = iterator.next() + if next == "'" { + closed = true + break + } + current.append(next) + } + guard closed else { throw Failure(message: "unterminated single quote") } + default: + inToken = true + current.append(character) + } + } + flush() + return tokens + } +} diff --git a/Sources/TesseraHarness/TerminalAnswerer.swift b/Sources/TesseraHarness/TerminalAnswerer.swift new file mode 100644 index 0000000..6c15e09 --- /dev/null +++ b/Sources/TesseraHarness/TerminalAnswerer.swift @@ -0,0 +1,140 @@ +/// Answers the queries a session writes to its terminal, as a terminal with a known colour +/// scheme would. +/// +/// A session asks for the terminal's colours with OSC 10 (foreground), OSC 11 (background), +/// and OSC 4 (palette slots), ending the round with one DA1. The answerer replies to each +/// colour query with the profile's fixed colours, echoing the query's terminator, and to +/// DA1 last, so a round ends at once instead of at its timeout. With no theme it answers DA1 +/// only, as a terminal that cannot report colours does. Every other byte, a frame's among +/// them, is ignored. +/// +/// A query is recognised when it is whole within one write, which is how a session emits it. +struct TerminalAnswerer: Sendable { + private static let bell: UInt8 = 0x07 + private static let escape: UInt8 = 0x1B + + /// The scheme to report, or `nil` for a terminal whose colours are unknown. + var theme: TerminalTheme? + + /// `rgb:RRRR/GGGG/BBBB`, each channel as 16 bits, as xterm reports it. + private static func report(_ color: TerminalTheme.RGB) -> String { + func channel(_ value: UInt8) -> String { + let hex = String(Int(value) * 0x101, radix: 16) + return String(repeating: "0", count: 4 - hex.count) + hex + } + return "rgb:\(channel(color.red))/\(channel(color.green))/\(channel(color.blue))" + } + + /// The bytes a terminal would send back for the queries in `written`, in query order. + func replies(to written: ArraySlice) -> [UInt8] { + var replies: [UInt8] = [] + var index = written.startIndex + while index < written.endIndex { + guard written[index] == Self.escape, index + 1 < written.endIndex else { + index += 1 + continue + } + switch written[index + 1] { + case UInt8(ascii: "]"): + guard let end = oscEnd(in: written, from: index + 2) else { + return replies + } + replies += answer( + osc: written[(index + 2).., from start: Int) + -> (bodyEnd: Int, terminator: [UInt8], next: Int)? + { + var index = start + while index < bytes.endIndex { + if bytes[index] == Self.bell { + return (index, [Self.bell], index + 1) + } + if bytes[index] == Self.escape { + guard index + 1 < bytes.endIndex else { + return nil + } + if bytes[index + 1] == UInt8(ascii: "\\") { + return (index, [Self.escape, UInt8(ascii: "\\")], index + 2) + } + return nil + } + index += 1 + } + return nil + } + + /// The index of the final byte of a CSI sequence whose parameters start at `start`. + private func csiFinal(in bytes: ArraySlice, from start: Int) -> Int? { + var index = start + while index < bytes.endIndex { + let byte = bytes[index] + if (0x40...0x7E).contains(byte) { + return index + } + guard (0x20...0x3F).contains(byte) else { + return nil + } + index += 1 + } + return nil + } + + /// `CSI c` and `CSI 0 c` ask for the primary device attributes. + private func isPrimaryDeviceAttributesQuery(_ parameters: ArraySlice) -> Bool { + parameters.isEmpty || parameters.elementsEqual([UInt8(ascii: "0")]) + } + + private func answer(osc body: ArraySlice, terminator: [UInt8]) -> [UInt8] { + guard let theme, let text = String(validating: body, as: UTF8.self) else { + return [] + } + let parts = text.split(separator: ";", omittingEmptySubsequences: false) + guard let selector = parts.first else { + return [] + } + var replies: [UInt8] = [] + func reply(_ prefix: String, _ color: TerminalTheme.RGB) { + replies += Array("\u{1B}]\(prefix);\(Self.report(color))".utf8) + terminator + } + switch selector { + case "10" where parts.count == 2 && parts[1] == "?": + reply("10", theme.foreground) + case "11" where parts.count == 2 && parts[1] == "?": + reply("11", theme.background) + case "4": + var pairs = parts.dropFirst() + while pairs.count >= 2 { + let slot = pairs.removeFirst() + let query = pairs.removeFirst() + if query == "?", let number = Int(slot), + TerminalTheme.palette.indices.contains(number) + { + reply("4;\(number)", TerminalTheme.palette[number]) + } + } + default: + break + } + return replies + } +} diff --git a/Sources/TesseraHarness/TerminalProfile.swift b/Sources/TesseraHarness/TerminalProfile.swift new file mode 100644 index 0000000..e5dd6eb --- /dev/null +++ b/Sources/TesseraHarness/TerminalProfile.swift @@ -0,0 +1,91 @@ +import TesseraTerminal + +/// The colour level a headless session renders with: the `colors=` option of the `term` +/// header. +package enum TerminalColors: String, CaseIterable, Equatable, Sendable { + case ansi16 = "16" + case indexed256 = "256" + case none + case truecolor = "true" + + /// The accepted spellings, in the order the grammar lists them. + package static let spellings = ["none", "16", "256", "true"] + + /// The override a session applies, so the renderer degrades colours to this level. + var override: ColorCapabilityOverride { + switch self { + case .ansi16: .force(.ansi16) + case .indexed256: .force(.indexed256) + case .none: .force(.noColor) + case .truecolor: .force(.truecolor) + } + } +} + +/// The colour scheme the harness reports when a session asks the terminal for its colours: +/// the `theme=` option of the `term` header. +/// +/// A scenario that names no theme runs against a terminal whose colours are unknown: the +/// harness answers no colour query, as a terminal that cannot report them would. The values +/// are fixed, so a golden never depends on any real terminal's palette. +package enum TerminalTheme: String, CaseIterable, Equatable, Sendable { + case dark + case light + + /// An 8-bit colour. + struct RGB: Equatable, Sendable { + var red: UInt8 + var green: UInt8 + var blue: UInt8 + + init(_ red: UInt8, _ green: UInt8, _ blue: UInt8) { + self.red = red + self.green = green + self.blue = blue + } + } + + /// The accepted spellings, in the order the grammar lists them. + package static let spellings = ["light", "dark"] + + /// ANSI slots 0 through 15 (OSC 4), xterm's defaults in both themes. + static let palette: [RGB] = [ + RGB(0x00, 0x00, 0x00), RGB(0xCD, 0x00, 0x00), RGB(0x00, 0xCD, 0x00), + RGB(0xCD, 0xCD, 0x00), RGB(0x00, 0x00, 0xEE), RGB(0xCD, 0x00, 0xCD), + RGB(0x00, 0xCD, 0xCD), RGB(0xE5, 0xE5, 0xE5), RGB(0x7F, 0x7F, 0x7F), + RGB(0xFF, 0x00, 0x00), RGB(0x00, 0xFF, 0x00), RGB(0xFF, 0xFF, 0x00), + RGB(0x5C, 0x5C, 0xFF), RGB(0xFF, 0x00, 0xFF), RGB(0x00, 0xFF, 0xFF), + RGB(0xFF, 0xFF, 0xFF), + ] + + /// The default foreground (OSC 10). + var foreground: RGB { + switch self { + case .dark: RGB(0xD4, 0xD4, 0xD4) + case .light: RGB(0x1E, 0x1E, 0x1E) + } + } + + /// The default background (OSC 11). + var background: RGB { + switch self { + case .dark: RGB(0x1E, 0x1E, 0x1E) + case .light: RGB(0xFF, 0xFF, 0xFF) + } + } +} + +/// The terminal a headless run presents to the application: what a scenario's `term` header +/// names. +package struct TerminalProfile: Equatable, Sendable { + /// The colour level the session renders with. Plain cells, with no colour, by default. + package var colors: TerminalColors + + /// The colour scheme the harness reports to colour queries, or `nil` for unknown colours. + package var theme: TerminalTheme? + + package init(colors: TerminalColors = .none, theme: TerminalTheme? = nil) { + self.colors = colors + self.theme = theme + } +} diff --git a/Tests/TesseraHarnessTests/Fixtures.swift b/Tests/TesseraHarnessTests/Fixtures.swift index 4bb6f70..03126a1 100644 --- a/Tests/TesseraHarnessTests/Fixtures.swift +++ b/Tests/TesseraHarnessTests/Fixtures.swift @@ -246,8 +246,27 @@ final class ObservedCounterApp: Application { } } +/// Shows the last events it received, one per line with the newest last, so a scenario can +/// assert what `key`, `type`, `paste`, and `bytes` delivered. +final class EventLogApp: Application { + private var log: [String] = [] + + var body: some View { + Text(log.suffix(6).joined(separator: "\n")) + } + + func apply(_ event: InputEvent) { + switch event { + case .key(let key): log.append("key \(KeyName.describe(key))") + case .paste(let text): log.append("paste \(text)") + case .resize(let size): log.append("resize \(size.columns)x\(size.rows)") + default: log.append("other") + } + } +} + enum Fixtures { - static let names = ["stack", "workspace", "greedy"] + static let names = ["stack", "workspace", "greedy", "event-log"] /// Every fixture app, registered once under the name the scenario corpus uses. @MainActor @@ -267,6 +286,7 @@ enum Fixtures { MainActorCounterApp() } registry.register("observed-counter", viewports: [.desktop]) { ObservedCounterApp() } + registry.register("event-log", viewports: [.desktop, .mobileScreen]) { EventLogApp() } return registry } } diff --git a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift new file mode 100644 index 0000000..c5a946b --- /dev/null +++ b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift @@ -0,0 +1,360 @@ +import Foundation +import TesseraTerminal +import TesseraTerminalCore +import Testing + +@testable import TesseraHarness + +private let passing = "app stack\nsize 40x10\nexpect text 0 \"Stack demo\"\n" +private let failing = "app stack\nsize 40x10\nexpect text 0 \"Stack demos\"\n" + +@MainActor +struct HarnessCLIScenarioTests { + private func run( + _ arguments: String..., + registry: ApplicationRegistry? = nil, + terminal: Bool = false + ) async -> CommandOutput { + await HarnessCLI.run( + arguments: arguments, + registry: registry ?? Fixtures.registry(), + inputIsTerminal: false, + outputIsTerminal: terminal, + configuration: .default + ) + } + + private func object(_ output: CommandOutput) throws -> [String: Any] { + try #require( + JSONSerialization.jsonObject(with: Data(output.stdout.utf8)) as? [String: Any]) + } + + private func reports(_ output: CommandOutput) throws -> [[String: Any]] { + try #require(try object(output)["scenarios"] as? [[String: Any]]) + } + + // MARK: run + + @Test + func + `run prints one JSON object with a report per scenario when stdout is not a terminal`() + async throws + { + let scratch = try ScratchDirectory() + try scratch.write("a.scn", passing) + try scratch.write("b.scn", passing) + + let output = await run("run", scratch.path) + + #expect(output.exitCode == 0) + #expect(output.stderr.isEmpty) + #expect(output.stdout.hasSuffix("}\n")) + #expect(output.stdout.filter { $0 == "\n" }.count == 1) + let object = try object(output) + #expect(Set(object.keys) == ["command", "exitCode", "scenarios"]) + #expect(object["command"] as? String == "run") + #expect(object["exitCode"] as? Int == 0) + let reports = try reports(output) + #expect(reports.count == 2) + #expect(reports.allSatisfy { $0["passed"] as? Bool == true }) + } + + @Test + func `run prints the human form and a summary on a terminal`() async throws { + let scratch = try ScratchDirectory() + try scratch.write("a.scn", passing) + try scratch.write("b.scn", failing) + + let output = await run("run", scratch.path, terminal: true) + + #expect(output.exitCode == 1) + #expect(output.stdout.contains("PASS \(scratch.appending("a.scn"))")) + #expect(output.stdout.contains("FAIL \(scratch.appending("b.scn"))")) + #expect(output.stdout.contains("expected: Stack demos")) + #expect(output.stdout.hasSuffix("2 scenarios, 1 passed, 1 failed\n")) + #expect(output.stderr.isEmpty) + } + + @Test + func `a summary of one scenario is singular`() async throws { + let scratch = try ScratchDirectory() + try scratch.write("a.scn", passing) + + let output = await run("run", scratch.path, "--pretty") + + #expect(output.stdout.hasSuffix("1 scenario, 1 passed, 0 failed\n")) + } + + @Test + func `a failed expectation exits 1 and the JSON says which line and statement`() + async throws + { + let scratch = try ScratchDirectory() + try scratch.write("b.scn", failing) + + let output = await run("run", scratch.path) + + #expect(output.exitCode == 1) + let object = try object(output) + #expect(object["exitCode"] as? Int == 1) + let failures = try #require( + try reports(output).first?["failures"] as? [[String: Any]]) + #expect(failures.first?["line"] as? Int == 3) + #expect(failures.first?["verb"] as? String == "expect text") + #expect(failures.first?["expected"] as? String == "Stack demos") + } + + @Test + func `a parse error exits 2 and is a failure with verb parse`() async throws { + let scratch = try ScratchDirectory() + try scratch.write("bad.scn", "app stack\nblink\n") + + let output = await run("run", scratch.path) + + #expect(output.exitCode == 2) + let failures = try #require( + try reports(output).first?["failures"] as? [[String: Any]]) + #expect(failures.count == 1) + #expect(failures[0]["verb"] as? String == "parse") + #expect(failures[0]["line"] as? Int == 2) + #expect(failures[0]["statement"] as? String == "blink") + } + + @Test + func `a path that is not a scenario exits 2`() async throws { + let scratch = try ScratchDirectory() + + let output = await run("run", scratch.appending("missing.scn")) + + #expect(output.exitCode == 2) + let failures = try #require( + try reports(output).first?["failures"] as? [[String: Any]]) + #expect(failures.first?["verb"] as? String == "usage") + } + + @Test + func `a harness failure exits 3, and the highest code of the run wins`() async throws { + let scratch = try ScratchDirectory() + try scratch.write("a-fails.scn", failing) + try scratch.write("b-bad.scn", "blink") + try scratch.write("c-shots.scn", "app stack\nsize 40x10\nshot s") + // A file where the goldens' directory should be: recording cannot write there. + try scratch.write("c-shots", "in the way") + + let withoutHarness = await run( + "run", scratch.appending("a-fails.scn"), scratch.appending("b-bad.scn")) + let all = await run("run", scratch.path, "--record") + + #expect(withoutHarness.exitCode == 2) + #expect(all.exitCode == 3) + let verbs = try reports(all).flatMap { report in + (report["failures"] as? [[String: Any]] ?? []).compactMap { $0["verb"] as? String } + } + #expect(verbs.sorted() == ["expect text", "harness", "parse"]) + } + + @Test + func `a duplicate name in the registry exits 3`() async throws { + let scratch = try ScratchDirectory() + try scratch.write("a.scn", passing) + var registry = Fixtures.registry() + registry.register("stack", viewports: [.desktop]) { StackApp() } + + let output = await run("run", scratch.path, registry: registry) + + #expect(output.exitCode == 3) + } + + @Test + func `run without a path is a usage error with the failure object`() async throws { + let output = await run("run") + + #expect(output.exitCode == 2) + let object = try object(output) + #expect(object["command"] as? String == "run") + #expect(object["exitCode"] as? Int == 2) + #expect((object["message"] as? String)?.hasPrefix("usage: run PATH...") == true) + } + + @Test + func `run validates its flags`() async { + let unknown = await run("run", "x.scn", "--frobnicate", "--pretty") + let noValue = await run("run", "x.scn", "--repeat", "--pretty") + let zero = await run("run", "x.scn", "--repeat", "0", "--pretty") + let word = await run("run", "x.scn", "--repeat", "many", "--pretty") + let twice = await run("run", "x.scn", "--repeat", "2", "--repeat", "3", "--pretty") + + #expect(unknown.exitCode == 2) + #expect( + unknown.stderr + == "error: unknown flag `--frobnicate`; accepted: --json, --pretty, --record, " + + "--repeat, --timing\n") + #expect(noValue.stderr.contains("`--repeat` needs a value")) + #expect( + zero.stderr.contains("`--repeat` needs a whole number of at least 1, got `0`")) + #expect(word.exitCode == 2) + #expect(twice.stderr.contains("`--repeat` is given twice")) + } + + @Test + func `record, repeat, and timing reach the runner`() async throws { + let scratch = try ScratchDirectory() + try scratch.write("a.scn", "app stack\nsize 40x10\nshot s") + + let recorded = await run("run", scratch.path, "--record", "--repeat", "2", "--timing") + let checked = await run("run", scratch.path) + + #expect(recorded.exitCode == 0) + let report = try #require(try reports(recorded).first) + #expect((report["recorded"] as? [[String: Any]])?.count == 1) + #expect((report["seconds"] as? [Double])?.count == 2) + #expect(checked.exitCode == 0) + #expect(try reports(checked)[0]["seconds"] == nil) + #expect(scratch.read("a/s.txt")?.hasPrefix("Stack demo\n") == true) + } + + @Test + func `run output is identical across runs`() async throws { + let scratch = try ScratchDirectory() + try scratch.write("a.scn", passing) + try scratch.write("b.scn", failing) + + let first = await run("run", scratch.path) + let second = await run("run", scratch.path) + + #expect(first == second) + } + + // MARK: shot and tree + + @Test + func `shot prints the screen as a golden holds it`() async { + let output = await run("shot", "stack", "40x8", "--pretty") + + #expect(output.exitCode == 0) + #expect( + output.stdout + == """ + Stack demo + ──────────────────────────────────────── + Name: - + left right + ──────────────────────────────────────── + 40 columns + + """) + } + + @Test + func `shot prints one JSON object with the rows and the size`() async throws { + let output = await run("shot", "stack", "mobile") + + #expect(output.exitCode == 0) + let object = try object(output) + #expect(Set(object.keys) == ["app", "command", "exitCode", "rows", "size"]) + #expect(object["app"] as? String == "stack") + #expect(object["command"] as? String == "shot") + #expect(object["size"] as? String == "40x6") + #expect((object["rows"] as? [String])?.first == "Stack demo") + #expect((object["rows"] as? [String])?.count == 6) + } + + @Test + func `shot defaults to desktop`() async throws { + let output = await run("shot", "stack") + + #expect(try object(output)["size"] as? String == "80x24") + } + + @Test + func `tree prints one line per node and the JSON carries the same lines`() async throws + { + let text = await run("tree", "stack", "40x8", "--pretty") + let json = await run("tree", "stack", "40x8") + + #expect(text.exitCode == 0) + #expect( + text.stdout.hasPrefix("root TupleView frame=0,0,40x8\n root/index(0) VStack")) + let object = try object(json) + #expect(Set(object.keys) == ["app", "command", "exitCode", "lines", "size"]) + #expect( + (object["lines"] as? [String])?.map { $0 + "\n" }.joined() == text.stdout) + } + + @Test + func + `shot and tree fail as usage errors for an unknown app, a bad size, or a greedy app`() + async throws + { + let unknown = await run("shot", "ghost", "--pretty") + let badSize = await run("tree", "stack", "huge", "--pretty") + let none = await run("shot", "--pretty") + let extra = await run("shot", "stack", "mobile", "more", "--pretty") + let greedy = await run("shot", "greedy", "mobile") + let duplicate = await run( + "shot", "stack", "--pretty", + registry: { + var registry = Fixtures.registry() + registry.register("stack", viewports: [.desktop]) { StackApp() } + return registry + }()) + + #expect(unknown.exitCode == 2) + #expect(unknown.stderr.contains("unknown app `ghost`; accepted: ")) + #expect(badSize.exitCode == 2) + #expect(badSize.stderr.contains("bad size `huge`")) + #expect(none.stderr == "error: usage: shot NAME [SIZE]\n") + #expect(extra.exitCode == 2) + #expect(greedy.exitCode == 2) + #expect((try object(greedy)["message"] as? String)?.contains("mobileScreen") == true) + #expect(duplicate.exitCode == 3) + } + + // MARK: check and replay + + @Test + func `check and replay say they are not yet available instead of passing`() async throws + { + let check = await run("check", "stack") + let replay = await run("replay", "run.trace", "--app", "stack") + + for (output, name) in [(check, "check"), (replay, "replay")] { + #expect(output.exitCode == 2, "\(name)") + let object = try object(output) + #expect(object["command"] as? String == name) + #expect(object["exitCode"] as? Int == 2) + #expect((object["message"] as? String)?.contains("is not yet available") == true) + } + } + + @Test + func `check and replay still validate their arguments`() async { + let noApp = await run("check", "--pretty") + let badSize = await run("check", "stack", "huge", "--pretty") + let noFile = await run("replay", "--app", "stack", "--pretty") + let noName = await run("replay", "run.trace", "--pretty") + + #expect(noApp.stderr == "error: usage: check NAME [SIZE]\n") + #expect(badSize.stderr.contains("bad size `huge`")) + #expect(noFile.stderr == "error: usage: replay FILE --app NAME\n") + #expect(noName.stderr == "error: usage: replay FILE --app NAME\n") + } + + // MARK: help + + @Test + func `help describes every command, and the overview names each one`() async { + let overview = await run("help") + + for command in HarnessCLI.commands { + #expect(overview.stdout.contains(command), "\(command)") + let help = await run("help", command) + #expect(help.stdout.hasPrefix("usage: \(command)"), "\(command)") + #expect(help.stdout.count > 60, "\(command)") + } + let runHelp = await run("help", "run") + for flag in ["--record", "--repeat N", "--timing", "--json", "--pretty"] { + #expect(runHelp.stdout.contains(flag), "\(flag)") + } + } +} diff --git a/Tests/TesseraHarnessTests/HarnessCLITests.swift b/Tests/TesseraHarnessTests/HarnessCLITests.swift index edf7654..fd05fbf 100644 --- a/Tests/TesseraHarnessTests/HarnessCLITests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLITests.swift @@ -185,7 +185,9 @@ struct HarnessCLITests { #expect(output.exitCode == 2) #expect( - output.stderr == "error: unknown command `jump`; accepted: apps, launch, help\n") + output.stderr + == "error: unknown command `jump`; accepted: apps, launch, run, shot, tree, check, " + + "replay, help\n") } @Test @@ -198,7 +200,7 @@ struct HarnessCLITests { @Test func `help prints real text for each command whatever stdout is`() async { - for command in ["apps", "launch", "help"] { + for command in HarnessCLI.commands { let output = await run("help", command) #expect(output.exitCode == 0) diff --git a/Tests/TesseraHarnessTests/RegistryTests.swift b/Tests/TesseraHarnessTests/RegistryTests.swift index b3ad33c..141f9f3 100644 --- a/Tests/TesseraHarnessTests/RegistryTests.swift +++ b/Tests/TesseraHarnessTests/RegistryTests.swift @@ -66,13 +66,13 @@ struct ApplicationRegistryTests { } @Test - func `a duplicate name fails the scenario runner with the app verb`() async throws { - let scenario = Scenario(app: "workspace", viewport: .desktop, lines: []) + func `a duplicate name fails the scenario player with the harness verb`() async throws { + let scenario = Scenario(app: "workspace") - let report = await ScenarioRunner.run(scenario, name: "t.scn", registry: duplicated()) + let report = await ScenarioPlayer.run(scenario, name: "t.scn", registry: duplicated()) #expect(report.passed == false) - #expect(report.failures.first?.verb == "app") + #expect(report.failures.first?.verb == "harness") #expect(report.failures.first?.message.contains("registered more than once") == true) } diff --git a/Tests/TesseraHarnessTests/ScenarioRunnerTests.swift b/Tests/TesseraHarnessTests/ScenarioRunnerTests.swift new file mode 100644 index 0000000..12bfe4a --- /dev/null +++ b/Tests/TesseraHarnessTests/ScenarioRunnerTests.swift @@ -0,0 +1,478 @@ +import Foundation +import TesseraCore +import TesseraRuntime +import TesseraTerminalInput +import Testing + +@testable import TesseraHarness + +/// Counts the apps the factory has built, so a scenario can see that it ran again. +@MainActor +private enum Builds { + static var count = 0 +} + +/// Shows how many apps have been built: it differs on every run of a scenario. +private final class TickApp: Application { + private let run: Int + + var body: some View { + Text("Run \(run)") + } + + init(run: Int) { + self.run = run + } + + func apply(_ event: InputEvent) {} +} + +private let stackScenario = """ + app stack + size 40x10 + type "Rob" + expect text 2 "Name: Rob" + shot after-typing + """ + +@MainActor +struct ScenarioRunnerFileTests { + private func registry() -> ApplicationRegistry { + var registry = Fixtures.registry() + registry.register("tick", viewports: [.desktop]) { + Builds.count += 1 + return TickApp(run: Builds.count) + } + return registry + } + + private func runner(record: Bool = false) -> ScenarioRunner { + ScenarioRunner(registry: registry(), record: record) + } + + // MARK: Discovery + + @Test + func + `a directory runs its scenarios in sorted order, at any depth, and skips the rest`() + async throws + { + let scratch = try ScratchDirectory() + try scratch.write("b.scn", "app stack\nsize 40x10\nshot a") + try scratch.write("a.scn", "app stack\nsize 40x10\nshot a") + try scratch.write("nested/deep/c.scn", "app stack\nsize 40x10\nshot a") + try scratch.write("nested/a.scn", "app stack\nsize 40x10\nshot a") + try scratch.write("notes.txt", "not a scenario") + try scratch.write(".hidden/z.scn", "not parsed") + + let reports = await runner().run(scratch.path) + + #expect( + reports.map(\.scenario) == [ + scratch.appending("a.scn"), scratch.appending("b.scn"), + scratch.appending("nested/a.scn"), scratch.appending("nested/deep/c.scn"), + ]) + } + + @Test + func `a trailing separator does not double the one between directory and file`() + async throws + { + let scratch = try ScratchDirectory() + try scratch.write("a.scn", "app stack\nsize 40x10") + + let reports = await runner().run(scratch.path + "/") + + #expect(reports.map(\.scenario) == [scratch.appending("a.scn")]) + } + + @Test + func `paths that cannot run report a usage failure and the run goes on`() async throws { + let scratch = try ScratchDirectory() + try scratch.write("ok.scn", "app stack\nsize 40x10") + try scratch.write("plain.txt", "app stack") + try FileManager.default.createDirectory( + atPath: scratch.appending("empty"), withIntermediateDirectories: true) + + let reports = await runner().run( + scratch.appending("missing.scn"), scratch.appending("plain.txt"), + scratch.appending("empty"), scratch.appending("ok.scn")) + + #expect(reports.map(\.passed) == [false, false, false, true]) + #expect( + reports.prefix(3).flatMap(\.failures).map(\.verb) == ["usage", "usage", "usage"]) + #expect(reports[0].failures[0].message == "no such file or directory") + #expect(reports[1].failures[0].message == "scenario files end in `.scn`") + #expect(reports[2].failures[0].message.hasPrefix("no scenario files (*.scn)")) + } + + @Test + func `no paths run nothing`() async { + let reports = await runner().run(paths: []) + + #expect(reports.isEmpty) + } + + @Test + func `a deliberately failing scenario fails four expectations and runs past each`() + async throws + { + let file = pathBesideSource("Scenarios/failing/mismatch.scn") + + let reports = await runner().run(file) + + #expect(reports.map(\.passed) == [false]) + #expect(reports[0].failures.map(\.line) == [6, 7, 8, 10]) + #expect( + reports[0].failures.map(\.verb) == [ + "expect text", "expect text", "expect node", "expect node", + ]) + } + + // MARK: Parse errors + + @Test + func `a scenario that does not parse reports a parse failure and others still run`() + async throws + { + let scratch = try ScratchDirectory() + let bad = try scratch.write("a-bad.scn", "app stack\nsize 40x10\nblink 3\n") + try scratch.write("b-good.scn", "app stack\nsize 40x10\nexpect text 0 \"Stack demo\"") + + let reports = await runner().run(scratch.path) + + #expect(reports.map(\.passed) == [false, true]) + let failure = try #require(reports.first?.failures.first) + #expect(failure.verb == "parse") + #expect(failure.line == 3) + #expect(failure.statement == "blink 3") + #expect( + failure.message.hasPrefix("unknown statement `blink`; accepted: app, size, term")) + #expect( + reports[0].description.hasPrefix( + "FAIL \(bad)\n\(bad):3: error: unknown statement `blink`")) + #expect( + reports[0].json.hasPrefix( + #"{"failures":[{"line":3,"message":"unknown statement `blink`; accepted:"#)) + #expect(reports[0].json.contains(#""verb":"parse""#)) + } + + @Test + func `a file with a byte order mark and CRLF line ends runs as written`() async throws { + let scratch = try ScratchDirectory() + let body = + "app stack\r\nsize 40x10\r\ntype \"Rob\"\r\nexpect text 2 \"Name: Rob\"\r\n" + try scratch.write("crlf.scn", "\u{FEFF}" + body) + + let reports = await runner().run(scratch.path) + + #expect(reports.map(\.passed) == [true], "\(reports)") + } + + @Test + func `a duplicate name in the registry fails every scenario with a harness failure`() + async throws + { + let scratch = try ScratchDirectory() + try scratch.write("a.scn", "app stack") + var registry = Fixtures.registry() + registry.register("stack", viewports: [.desktop]) { StackApp() } + + let reports = await ScenarioRunner(registry: registry).run(scratch.path) + + #expect(reports.count == 1) + #expect(reports[0].failures.first?.verb == "harness") + #expect( + reports[0].failures.first?.message.contains("registered more than once") == true) + } + + // MARK: Goldens + + @Test + func `a shot with no golden fails without record and says how to create it`() + async throws + { + let scratch = try ScratchDirectory() + try scratch.write("stack.scn", stackScenario) + + let reports = await runner().run(scratch.path) + + let failure = try #require(reports.first?.failures.first) + #expect(failure.verb == "shot") + #expect(failure.line == 5) + #expect(failure.message.contains("has no golden at")) + #expect(failure.message.contains("run with --record")) + #expect(failure.screen == nil) + #expect(scratch.read("stack/after-typing.txt") == nil) + } + + @Test + func `record writes the goldens, and the next run without record passes`() async throws + { + let scratch = try ScratchDirectory() + try scratch.write("stack.scn", stackScenario) + + let recorded = await runner(record: true).run(scratch.path) + let checked = await runner().run(scratch.path) + + #expect(recorded.map(\.passed) == [true]) + #expect( + recorded[0].recorded == [ + GoldenChange(path: scratch.appending("stack/after-typing.txt"), change: .created) + ]) + #expect(checked.map(\.passed) == [true]) + #expect(checked[0].recorded.isEmpty) + #expect( + scratch.read("stack/after-typing.txt") + == """ + Stack demo + ──────────────────────────────────────── + Name: Rob + left right + ──────────────────────────────────────── + 40 columns + + """) + } + + @Test + func `a drifted golden fails without record with the first differing row`() async throws + { + let scratch = try ScratchDirectory() + try scratch.write("stack.scn", stackScenario) + _ = await runner(record: true).run(scratch.path) + let golden = scratch.appending("stack/after-typing.txt") + try Data( + (scratch.read("stack/after-typing.txt") ?? "") + .replacingOccurrences(of: "Name: Rob", with: "Name: Bob").utf8 + ).write(to: URL(fileURLWithPath: golden)) + + let reports = await runner().run(scratch.path) + + #expect(reports.map(\.passed) == [false]) + let failure = try #require(reports.first?.failures.first) + #expect(failure.verb == "shot") + #expect(failure.expected == "Name: Bob") + #expect(failure.actual == "Name: Rob") + #expect(failure.message.contains("differs from")) + #expect(failure.message.contains("at row 2")) + #expect(failure.message.contains("rerun with --record and review")) + #expect(failure.screen?.count == 6) + #expect(scratch.read("stack/after-typing.txt")?.contains("Name: Bob") == true) + } + + @Test + func `record rewrites a drifted golden and lists what changed`() async throws { + let scratch = try ScratchDirectory() + try scratch.write("stack.scn", stackScenario) + try scratch.write("stack/after-typing.txt", "old\n") + + let reports = await runner(record: true).run(scratch.path) + + #expect(reports.map(\.passed) == [true]) + #expect( + reports[0].recorded == [ + GoldenChange(path: scratch.appending("stack/after-typing.txt"), change: .updated) + ]) + #expect(reports[0].description.contains("recorded")) + #expect(reports[0].description.contains("(updated)")) + #expect(scratch.read("stack/after-typing.txt")?.hasPrefix("Stack demo\n") == true) + } + + @Test + func `record leaves an unchanged golden alone and lists nothing`() async throws { + let scratch = try ScratchDirectory() + try scratch.write("stack.scn", stackScenario) + _ = await runner(record: true).run(scratch.path) + + let again = await runner(record: true).run(scratch.path) + + #expect(again[0].passed) + #expect(again[0].recorded.isEmpty) + #expect(!again[0].json.contains("recorded")) + } + + @Test + func `a golden checked out with CRLF line ends fails and says to check it out with LF`() + async throws + { + let scratch = try ScratchDirectory() + try scratch.write("stack.scn", stackScenario) + _ = await runner(record: true).run(scratch.path) + let lf = try #require(scratch.read("stack/after-typing.txt")) + try scratch.write( + "stack/after-typing.txt", lf.replacingOccurrences(of: "\n", with: "\r\n")) + + let reports = await runner().run(scratch.path) + + let failure = try #require(reports.first?.failures.first) + #expect(failure.message.contains("CRLF line endings")) + #expect(failure.message.contains("eol=lf")) + } + + @Test + func `a golden is the trimmed screen with trailing empty rows dropped`() { + #expect(GoldenStore.text(of: ["a", "", "b", "", ""]) == "a\n\nb\n") + #expect(GoldenStore.text(of: []) == "\n") + #expect(GoldenStore.text(of: [""]) == "\n") + } + + @Test + func `goldens live in a directory named like the scenario file`() { + let store = GoldenStore( + scenarioPath: "Examples/Scenarios/counter-basics.scn", record: false) + + #expect(store.directory == "Examples/Scenarios/counter-basics") + #expect(store.path(for: "reset") == "Examples/Scenarios/counter-basics/reset.txt") + } + + // MARK: Determinism, repeat, timing + + @Test + func `two runs of the same scenarios produce the same bytes`() async throws { + let scratch = try ScratchDirectory() + try scratch.write("stack.scn", stackScenario) + try scratch.write("bad.scn", "app stack\nexpect text 0 \"wrong\"\ntree") + _ = await runner(record: true).run(scratch.appending("stack.scn")) + + let first = await runner().run(scratch.path).map(\.json) + let second = await runner().run(scratch.path).map(\.json) + + #expect(first == second) + #expect(first.count == 2) + } + + @Test + func `repeat runs a scenario again and a deterministic one does not diverge`() + async throws + { + let scratch = try ScratchDirectory() + try scratch.write("stack.scn", stackScenario) + _ = await runner(record: true).run(scratch.path) + var repeating = runner() + repeating.repetitions = 3 + + let reports = await repeating.run(scratch.path) + + #expect(reports.map(\.passed) == [true]) + } + + @Test + func `repeat fails a scenario whose output differs from run to run`() async throws { + let scratch = try ScratchDirectory() + try scratch.write("tick.scn", "app tick\nsize 20x2\ntree\nexpect text 0 \"Run 1\"") + Builds.count = 0 + var repeating = runner() + repeating.repetitions = 3 + + let reports = await repeating.run(scratch.path) + + #expect(Builds.count == 3) + let failures = try #require(reports.first?.failures) + #expect(failures.map(\.verb) == ["repeat", "repeat"]) + #expect(failures[0].statement == "--repeat 3") + #expect(failures[0].message.hasPrefix("run 2 of 3 differs from run 1: ")) + #expect(failures[0].message.contains("it has 1 failures where run 1 has 0")) + } + + @Test + func `only the first of several repeated runs records`() async throws { + let scratch = try ScratchDirectory() + try scratch.write("stack.scn", stackScenario) + var repeating = runner(record: true) + repeating.repetitions = 2 + + let reports = await repeating.run(scratch.path) + + #expect(reports.map(\.passed) == [true]) + #expect(reports[0].recorded.count == 1) + } + + @Test + func `timing adds the seconds of each run and nothing else changes`() async throws { + let scratch = try ScratchDirectory() + try scratch.write("stack.scn", stackScenario) + _ = await runner(record: true).run(scratch.path) + var timed = runner() + timed.measuresTime = true + timed.repetitions = 2 + + let untimed = await runner().run(scratch.path) + let reports = await timed.run(scratch.path) + + #expect(untimed[0].seconds.isEmpty) + #expect(!untimed[0].json.contains("seconds")) + #expect(reports[0].seconds.count == 2) + #expect(reports[0].json.contains(#""seconds":["#)) + #expect(reports[0].comparable == untimed[0].comparable) + #expect(reports[0].description.hasPrefix("PASS \(scratch.appending("stack.scn")) (")) + } +} + +@MainActor +struct ScenarioReportShapeTests { + private let failing = ScenarioReport( + scenario: "a.scn", + failures: [ + ScenarioFailure( + line: 4, statement: "expect text 0 \"x\"", verb: "expect text", + message: "row 0 text differs", expected: "x", actual: "y", screen: ["y"]), + ScenarioFailure(line: 6, statement: "key Tab", verb: "harness", message: "m"), + ], + observations: [ScenarioObservation(line: 2, verb: "shot", name: "s", lines: ["y"])] + ) + + @Test + func `the JSON form is one compact sorted-key line with the promised fields`() throws { + let json = failing.json + + #expect(!json.contains("\n")) + #expect( + json + == #"{"failures":[{"actual":"y","expected":"x","line":4,"message":"row 0 text differs","# + + #""screen":["y"],"statement":"expect text 0 \"x\"","verb":"expect text"},"# + + #"{"line":6,"message":"m","statement":"key Tab","verb":"harness"}],"# + + #""observations":[{"line":2,"lines":["y"],"name":"s","verb":"shot"}],"# + + #""passed":false,"scenario":"a.scn"}"#) + } + + @Test + func `a passing report is passed true with empty arrays`() { + let report = ScenarioReport(scenario: "ok.scn", failures: []) + + #expect( + report.json + == #"{"failures":[],"observations":[],"passed":true,"scenario":"ok.scn"}"#) + #expect(report.description == "PASS ok.scn") + } + + @Test + func `the human form shows the statement, expected, actual, and the screen`() { + #expect( + failing.description + == """ + FAIL a.scn + line 4: expect text 0 "x" + row 0 text differs + expected: x + actual: y + screen: + 0| y + line 6: key Tab + m + """) + } + + @Test + func `a parse failure prints as FILE LINE error and a usage failure as FILE error`() { + let parse = ScenarioReport.parseFailure( + ScenarioParseError(file: "p.scn", line: 3, source: "blink", message: "unknown")) + let usage = ScenarioReport.failure( + scenario: "dir", verb: "usage", message: "no such file") + + #expect(parse.description == "FAIL p.scn\np.scn:3: error: unknown") + #expect(usage.description == "FAIL dir\ndir: error: no such file") + #expect(parse.failures[0].statement == "blink") + #expect(parse.failures[0].message == "unknown") + } +} diff --git a/Tests/TesseraHarnessTests/ScenarioTests.swift b/Tests/TesseraHarnessTests/ScenarioTests.swift index 4af7b3f..ee85187 100644 --- a/Tests/TesseraHarnessTests/ScenarioTests.swift +++ b/Tests/TesseraHarnessTests/ScenarioTests.swift @@ -17,14 +17,29 @@ struct ScenarioParserTests { # comment app stack size mobile + term colors=16 theme=light type "hi there" # trailing comment """ ) #expect(scenario.app == "stack") + #expect(scenario.appLine == SourceLine(number: 2, text: "app stack")) #expect(scenario.viewport == .mobile) + #expect(scenario.sizeLine == SourceLine(number: 3, text: "size mobile")) + #expect(scenario.terminal == TerminalProfile(colors: .ansi16, theme: .light)) #expect(scenario.lines.map(\.statement) == [.type("hi there")]) - #expect(scenario.lines.first?.number == 4) + #expect(scenario.lines.first?.number == 5) + } + + @Test + func `a scenario without a term header runs on plain cells with unknown colours`() + throws + { + let scenario = try parse("app stack") + + #expect(scenario.terminal == TerminalProfile(colors: .none, theme: nil)) + #expect(scenario.viewport == .desktop) + #expect(scenario.sizeLine == nil) } @Test @@ -53,32 +68,107 @@ struct ScenarioParserTests { } #expect(error?.line == 2) + #expect(error?.source == "size 10x10") #expect(error?.description.hasPrefix("t.scn:2: error: ") == true) #expect(error?.message.contains("resize") == true) } + @Test + func `app and term after a body statement are header errors`() { + for header in ["app stack", "term colors=16"] { + let error = #expect(throws: ScenarioParseError.self) { + try parse("app stack\nkey Tab\n\(header)") + } + + #expect(error?.line == 3, "\(header)") + #expect(error?.message.contains("must precede the first body statement") == true) + } + } + @Test func `unknown statements list the accepted vocabulary`() { let error = #expect(throws: ScenarioParseError.self) { - try parse("jump 3") + try parse("app stack\njump 3") } - #expect(error?.message.contains("accepted: app, size, resize, key") == true) + #expect(error?.line == 2) + #expect(error?.message.contains("unknown statement `jump`") == true) + #expect( + error?.message.contains("accepted: app, size, term, resize, key, type") == true) } @Test - func `reserved verbs are reported as not yet expressible`() { + func `unknown expectations list the accepted kinds`() { let error = #expect(throws: ScenarioParseError.self) { - try parse("mouse click 3,4") + try parse("app stack\nexpect colour red") } - #expect(error?.message.contains("not yet expressible") == true) + #expect( + error?.message == "unknown expectation `colour`; accepted: text, screen, node") + } + + @Test + func `every statement form the grammar defers is refused by name`() throws { + let samples: [String: String] = [ + "wait": "wait", + "settle": "settle", + "focus": "focus role=button", + "mouse": "mouse click 3,4", + "hit": "hit 1,2", + "dump": "dump", + "check": "check", + "expect cell": "expect cell 1,2 bold", + "expect cost": "expect cost bodies<=1", + "expect focused": "expect focused role=button", + "expect wireframe": "expect wireframe docs/design/a.md 1", + "expect node SEL visible": "expect node type=Text visible", + "expect node SEL clipped": "expect node type=Text clipped", + "expect node SEL count N": "expect node type=Text count 2", + "shot NAME SEL": "shot a type=Text", + "tree SEL": "tree type=Text", + "term glyphs=": "term glyphs=ascii", + "term keyboard=": "term keyboard=kitty", + "term theme=probe": "term theme=probe", + ] + #expect(Set(samples.keys) == Set(ScenarioGrammar.notYetExpressible.map(\.form))) + + for (form, sample) in samples { + let error = #expect(throws: ScenarioParseError.self) { + try parse("app stack\n" + sample) + } + + #expect(error?.line == 2, "\(form)") + #expect( + error?.message.hasPrefix("`\(form)` is not yet expressible: it needs ") == true, + "\(form): \(error?.message ?? "no error")") + } + } + + @Test + func + `the README's not-yet-expressible table lists exactly the forms the parser refuses`() + throws + { + let readme = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("Examples/Scenarios/README.md") + let text = try String(contentsOf: readme, encoding: .utf8) + let section = try #require( + text.components(separatedBy: "\n## ").first { $0.hasPrefix("Not yet expressible") }) + + var forms: [String] = [] + for line in section.components(separatedBy: "\n") where line.hasPrefix("| `") { + let cell = line.dropFirst(3) + forms.append(String(cell.prefix { $0 != "`" })) + } + + #expect(forms.sorted() == ScenarioGrammar.notYetExpressible.map(\.form).sorted()) } @Test func `unknown key names list the accepted set`() { let error = #expect(throws: ScenarioParseError.self) { - try parse("key Hyperspace") + try parse("app stack\nkey Hyperspace") } #expect(error?.message.contains("ArrowLeft") == true) @@ -98,7 +188,7 @@ struct ScenarioParserTests { } @Test - func `frame expectations accept one token or the plan's two-token form`() throws { + func `frame expectations accept one token or the two-token form`() throws { let scenario = try parse( "app stack\nexpect node id=root frame 1,2,3x4\nexpect node id=root frame 1,2 3x4" ) @@ -109,26 +199,167 @@ struct ScenarioParserTests { @Test func `unterminated quotes fail with the line number`() { let error = #expect(throws: ScenarioParseError.self) { - try parse("\ntype \"oops") + try parse("app stack\n\ntype \"oops") } - #expect(error?.line == 2) + #expect(error?.line == 3) } @Test func `duplicate shot names are rejected`() { #expect(throws: ScenarioParseError.self) { - try parse("shot a\nshot a") + try parse("app stack\nshot a\nshot a") + } + } + + @Test + func `a CRLF file parses as an LF file does, with the same line numbers`() throws { + let lf = "# header\napp stack\nsize 40x10\ntype \"hi\"\nexpect text 0 \"x\"\n" + let crlf = lf.replacingOccurrences(of: "\n", with: "\r\n") + + let fromLF = try parse(lf) + let fromCRLF = try parse(crlf) + + #expect(fromCRLF == fromLF) + #expect(fromCRLF.lines.map(\.number) == [4, 5]) + #expect(fromCRLF.lines.map(\.source) == ["type \"hi\"", "expect text 0 \"x\""]) + } + + @Test + func `a parse error in a CRLF file reports the right line`() { + let error = #expect(throws: ScenarioParseError.self) { + try parse("app stack\r\nsize 40x10\r\nblink\r\n") + } + + #expect(error?.line == 3) + #expect(error?.source == "blink") + } + + @Test + func `bytes, hex escapes, and paste carry their text`() throws { + let scenario = try parse( + #""" + app stack + bytes "\x1b[A" + paste "a b\nc" + type "A\x42" + """# + ) + + #expect( + scenario.lines.map(\.statement) == [ + .bytes([0x1B, 0x5B, 0x41]), .paste("a b\nc"), .type("AB"), + ]) + } + + @Test + func `bad escapes name what is accepted`() { + let cases = [ + (#"type "\x1""#, "needs two hex digits"), + (#"type "\xZZ""#, "needs two hex digits"), + (#"type "\x80""#, "ASCII"), + (#"type "\q""#, #"accepted: \n \t \" \\ \xHH"#), + ] + for (line, expected) in cases { + let error = #expect(throws: ScenarioParseError.self) { + try parse("app stack\n" + line) + } + + #expect( + error?.message.contains(expected) == true, "\(line): \(error?.message ?? "")") + } + } + + @Test + func `expect screen reads its rows up to the delimiter line`() throws { + let scenario = try parse( + """ + app stack + expect screen < ScenarioReport { +struct ScenarioPlayerTests { + private func run( + _ source: String, registry: ApplicationRegistry = Fixtures.registry() + ) async throws -> ScenarioReport { let scenario = try parse(source) - return await ScenarioRunner.run( - scenario, name: "t.scn", registry: Fixtures.registry()) + return await ScenarioPlayer.run(scenario, name: "t.scn", registry: registry) } @Test @@ -137,6 +368,8 @@ struct ScenarioRunnerTests { #expect(report.passed == false) let failure = try #require(report.failures.first) + #expect(failure.verb == "expect text") + #expect(failure.line == 3) #expect(failure.expected == "Stack demos") #expect(failure.actual == "Stack demo") #expect(failure.screen?.first == "Stack demo") @@ -200,6 +433,8 @@ struct ScenarioRunnerTests { let failure = try #require(report.failures.first) #expect(failure.verb == "size") + #expect(failure.line == 2) + #expect(failure.statement == "size mobile") #expect(failure.message.contains("mobileScreen")) #expect(failure.message.contains("`greedy` has no natural height")) } @@ -222,14 +457,113 @@ struct ScenarioRunnerTests { } @Test - func `an app the registry lacks fails the run with the app verb`() async throws { - let scenario = Scenario(app: "ghost", viewport: .desktop, lines: []) + func `bytes parse into keys, a bare escape, and a bracketed paste`() async throws { + let report = try await run( + #""" + app event-log + size 30x8 + bytes "\x1b[B" + bytes "\x1b[1;5C" + bytes "\x1b" + bytes "\x1b[200~x y\x1b[201~" + expect screen < ScenarioReport { - let url = Self.scenarios.appendingPathComponent(relativePath) - let source = try String(contentsOf: url, encoding: .utf8) - 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) async throws - { - let first = try await report(for: file) - let second = try await report(for: file) - - #expect(first.passed) - #expect(first.json() == second.json()) - } - - @Test - func `the failing scenario fails four expectations`() async throws { - let report = try await report(for: "failing/mismatch.scn") - - #expect(report.failures.map(\.line) == [6, 7, 8, 10]) - } -} diff --git a/Tests/TesseraHarnessTests/ScratchDirectory.swift b/Tests/TesseraHarnessTests/ScratchDirectory.swift new file mode 100644 index 0000000..0ab933f --- /dev/null +++ b/Tests/TesseraHarnessTests/ScratchDirectory.swift @@ -0,0 +1,49 @@ +import Foundation + +/// The path of `relative` in the directory that holds the source file `file`. +/// +/// This splits the compiler's own path string at either separator instead of going through a +/// file URL, whose `path` is not the native spelling on every platform. +func pathBesideSource(_ relative: String, file: String = #filePath) -> String { + var parts = file.split(omittingEmptySubsequences: false) { $0 == "/" || $0 == "\\" } + parts.removeLast() + return (parts + [Substring(relative)]).joined(separator: "/") +} + +/// A temporary directory a test fills with scenario files, removed when the test ends. +final class ScratchDirectory: Sendable { + let path: String + + init() throws { + path = NSTemporaryDirectory() + "tessera-harness-\(UUID().uuidString)" + try FileManager.default.createDirectory( + atPath: path, withIntermediateDirectories: true) + } + + /// The path of `relative` inside the directory. + func appending(_ relative: String) -> String { + "\(path)/\(relative)" + } + + /// Writes `contents` to `relative`, creating directories, and returns its path. + @discardableResult + func write(_ relative: String, _ contents: String) throws -> String { + let parent = relative.split(separator: "/").dropLast().joined(separator: "/") + try FileManager.default.createDirectory( + atPath: appending(parent), withIntermediateDirectories: true) + let file = appending(relative) + try Data(contents.utf8).write(to: URL(fileURLWithPath: file)) + return file + } + + /// The text of `relative`, or `nil` when there is no such file. + func read(_ relative: String) -> String? { + FileManager.default.contents(atPath: appending(relative)).flatMap { + String(validating: $0, as: UTF8.self) + } + } + + deinit { + try? FileManager.default.removeItem(atPath: path) + } +} diff --git a/Tests/TesseraHarnessTests/TerminalAnswererTests.swift b/Tests/TesseraHarnessTests/TerminalAnswererTests.swift new file mode 100644 index 0000000..945f4bc --- /dev/null +++ b/Tests/TesseraHarnessTests/TerminalAnswererTests.swift @@ -0,0 +1,201 @@ +import TesseraTerminal +import TesseraTerminalCore +import Testing + +@testable import TesseraHarness + +private func bytes(_ text: String) -> ArraySlice { + ArraySlice(text.utf8) +} + +private func text(_ bytes: [UInt8]) -> String { + String(validating: bytes, as: UTF8.self) ?? "" +} + +/// The bytes a session writes for one colour round: OSC 10, OSC 11, OSC 4 for slots 0 +/// through 15, then the round's single DA1. +private let colourRound: String = + "\u{1B}]10;?\u{1B}\\\u{1B}]11;?\u{1B}\\" + + (0..<16).map { "\u{1B}]4;\($0);?\u{1B}\\" }.joined() + "\u{1B}[c" + +struct TerminalAnswererTests { + @Test + func `a dark theme answers every query of a round in order and DA1 last`() { + let reply = text(TerminalAnswerer(theme: .dark).replies(to: bytes(colourRound))) + + var expected = "\u{1B}]10;rgb:d4d4/d4d4/d4d4\u{1B}\\" + expected += "\u{1B}]11;rgb:1e1e/1e1e/1e1e\u{1B}\\" + let slots = [ + "0000/0000/0000", "cdcd/0000/0000", "0000/cdcd/0000", "cdcd/cdcd/0000", + "0000/0000/eeee", "cdcd/0000/cdcd", "0000/cdcd/cdcd", "e5e5/e5e5/e5e5", + "7f7f/7f7f/7f7f", "ffff/0000/0000", "0000/ffff/0000", "ffff/ffff/0000", + "5c5c/5c5c/ffff", "ffff/0000/ffff", "0000/ffff/ffff", "ffff/ffff/ffff", + ] + for (slot, value) in slots.enumerated() { + expected += "\u{1B}]4;\(slot);rgb:\(value)\u{1B}\\" + } + expected += "\u{1B}[?62;22c" + #expect(reply == expected) + } + + @Test + func `a light theme reports the opposite foreground and background`() { + let reply = text( + TerminalAnswerer(theme: .light).replies( + to: bytes("\u{1B}]10;?\u{1B}\\\u{1B}]11;?\u{1B}\\"))) + + #expect( + reply == "\u{1B}]10;rgb:1e1e/1e1e/1e1e\u{1B}\\\u{1B}]11;rgb:ffff/ffff/ffff\u{1B}\\") + } + + @Test + func `unknown colours answer DA1 alone, so the round ends at once`() { + let reply = text(TerminalAnswerer(theme: nil).replies(to: bytes(colourRound))) + + #expect(reply == "\u{1B}[?62;22c") + } + + @Test + func `a background-only round gets the background and DA1`() { + let reply = text( + TerminalAnswerer(theme: .dark).replies(to: bytes("\u{1B}]11;?\u{1B}\\\u{1B}[c"))) + + #expect(reply == "\u{1B}]11;rgb:1e1e/1e1e/1e1e\u{1B}\\\u{1B}[?62;22c") + } + + @Test + func `a reply ends the way its query did`() { + let reply = text( + TerminalAnswerer(theme: .dark).replies(to: bytes("\u{1B}]11;?\u{07}"))) + + #expect(reply == "\u{1B}]11;rgb:1e1e/1e1e/1e1e\u{07}") + } + + @Test + func `one OSC 4 query can ask for several slots, and only slots 0 through 15 answer`() { + let reply = text( + TerminalAnswerer(theme: .dark).replies(to: bytes("\u{1B}]4;1;?;2;?;16;?\u{1B}\\"))) + + #expect( + reply + == "\u{1B}]4;1;rgb:cdcd/0000/0000\u{1B}\\\u{1B}]4;2;rgb:0000/cdcd/0000\u{1B}\\") + } + + @Test + func `a request to set a colour is not a query and gets no answer`() { + let reply = text( + TerminalAnswerer(theme: .dark).replies( + to: bytes("\u{1B}]4;1;rgb:ff/00/00\u{1B}\\\u{1B}]10;#ffffff\u{07}"))) + + #expect(reply.isEmpty) + } + + @Test + func `a frame's own sequences are ignored, hyperlinks and colours included`() { + let frame = + "\u{1B}[0m\u{1B}[38;5;196mred\u{1B}[0m\u{1B}]8;;https://example.com\u{1B}\\link" + + "\u{1B}]8;;\u{1B}\\\u{1B}[2;1H\u{1B}[?25l\u{1B}_Ga=d,d=A\u{1B}\\" + + #expect(TerminalAnswerer(theme: .dark).replies(to: bytes(frame)).isEmpty) + } + + @Test + func `only the primary device attributes query gets the DA1 reply`() { + let answerer = TerminalAnswerer(theme: nil) + + #expect(text(answerer.replies(to: bytes("\u{1B}[0c"))) == "\u{1B}[?62;22c") + #expect(answerer.replies(to: bytes("\u{1B}[>c")).isEmpty) + #expect(answerer.replies(to: bytes("\u{1B}[1c")).isEmpty) + #expect(answerer.replies(to: bytes("\u{1B}[=c")).isEmpty) + } + + @Test + func `a sequence cut off by the end of a write is not answered`() { + let answerer = TerminalAnswerer(theme: .dark) + + #expect(answerer.replies(to: bytes("\u{1B}]11;?")).isEmpty) + #expect(answerer.replies(to: bytes("\u{1B}[")).isEmpty) + #expect(answerer.replies(to: bytes("\u{1B}")).isEmpty) + } +} + +struct TerminalProfileTests { + @Test + func `colour levels map to the session's forced capability`() { + #expect(TerminalColors.none.override == .force(.noColor)) + #expect(TerminalColors.ansi16.override == .force(.ansi16)) + #expect(TerminalColors.indexed256.override == .force(.indexed256)) + #expect(TerminalColors.truecolor.override == .force(.truecolor)) + #expect(TerminalColors(rawValue: "16") == .ansi16) + #expect(TerminalColors(rawValue: "256") == .indexed256) + #expect(TerminalColors(rawValue: "true") == .truecolor) + #expect(TerminalColors(rawValue: "none") == TerminalColors.none) + #expect(Set(TerminalColors.spellings) == Set(TerminalColors.allCases.map(\.rawValue))) + #expect(Set(TerminalTheme.spellings) == Set(TerminalTheme.allCases.map(\.rawValue))) + } + + @Test + func `a profile with no arguments is plain cells and unknown colours`() { + #expect(TerminalProfile() == TerminalProfile(colors: .none, theme: nil)) + } +} + +@MainActor +struct HeadlessDeviceTests { + @Test + func `the device answers a colour round written through its seam`() async throws { + let device = HeadlessTerminalDevice( + size: TerminalSize(columns: 20, rows: 4), theme: .dark) + let seam = device.seam + var replies = seam.bytes().makeAsyncIterator() + + let written = try await seam.write(bytes(colourRound)) + let reply = await replies.next() + + #expect(written == colourRound.utf8.count) + #expect(reply.map(text)?.hasPrefix("\u{1B}]10;rgb:d4d4/d4d4/d4d4\u{1B}\\") == true) + #expect(reply.map(text)?.hasSuffix("\u{1B}[?62;22c") == true) + } + + @Test + func `a frame written through the seam draws no reply, and finishing ends the input`() + async throws + { + let device = HeadlessTerminalDevice( + size: TerminalSize(columns: 20, rows: 4), theme: .light) + let seam = device.seam + var replies = seam.bytes().makeAsyncIterator() + + _ = try await seam.write(bytes("\u{1B}[2;1Hhello")) + await device.finish() + + #expect(await replies.next() == nil) + } + + @Test + func `the device reports its size and a resize changes it`() async throws { + let device = HeadlessTerminalDevice(size: TerminalSize(columns: 20, rows: 4)) + + #expect(try await device.seam.size() == TerminalSize(columns: 20, rows: 4)) + await device.resize(to: TerminalSize(columns: 30, rows: 5)) + #expect(try await device.seam.size() == TerminalSize(columns: 30, rows: 5)) + } + + @Test + func `a headless run takes the profile's colour level and theme and still draws`() + async throws + { + let registry = Fixtures.registry() + let drawsStack: @MainActor (any ScenarioHost) async throws -> Void = { host in + #expect(host.screenRows.first == "Stack demo") + } + for profile in [ + TerminalProfile(colors: .truecolor, theme: .dark), + TerminalProfile(colors: .ansi16, theme: .light), + TerminalProfile(colors: .indexed256), + ] { + try await registry.withHeadlessHost( + "stack", at: .desktop, terminal: profile, drawsStack) + } + } +} diff --git a/docs/architecture/developer-tooling.md b/docs/architecture/developer-tooling.md index 409fbd3..d641dec 100644 --- a/docs/architecture/developer-tooling.md +++ b/docs/architecture/developer-tooling.md @@ -53,11 +53,11 @@ Read the diagram as one scenario run. `tessera run` and `swift test` are two way | 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` | 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 | +| Scenario language and runner | both | grammar in `Examples/Scenarios/README.md`, parser, runner, and report in `TesseraHarness` | built for the statements the harness can honour today; the rest are refused by name until their prerequisite exists | +| `tessera` CLI and `mise run dev:*` | agents first | `Examples/Sources/TesseraDev`, `HarnessCLI` in `TesseraHarness`, `mise-tasks/dev/` | `apps`, `launch`, `run`, `shot`, `tree`, and `help` built; `check` and `replay` are named and refuse until the projection and the trace exist | | 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 | +| Shots (text, grid, SVG) | both | goldens beside the scenario; pictures in `.build/tessera/` | text shots and goldens built (`NAME.txt`); the styled grid and the pictures designed | | Trace and cost | both | `TesseraHarness` | designed | | Catalog conformance and the gallery | both | `tessera conform`, `DevTools/gallery/` | designed | | Keyboard coverage walk | agents | `tessera coverage` | designed | @@ -96,25 +96,29 @@ Built today: the driver delivers its starting size to the app as a `.resize` thr 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. ```text -# Tab moves focus from the name field to Submit. -app form-basics -size desktop -term colors=256 keyboard=kitty - -key Tab -expect focused role=button label="Submit" -shot after-tab -check +# The counter steps on `+` and `-`, resets on `0`, and ignores everything else. +app counter +size mobile +term colors=256 theme=dark + +type "++" +bytes "+\x1b[A" +expect text 2 "Count 3" +shot after-steps ``` -| Group | Verbs | -| --- | --- | -| Header | `app NAME`, `size desktop\|mobile\|mobileScreen\|WxH`, `term colors=none\|16\|256\|true glyphs=ascii keyboard=legacy\|kitty theme=light\|dark\|probe` | -| Input | `resize WxH`, `key Tab`, `type "hi"`, `paste "..."`, `bytes "\x1b[A"`, `focus SEL`, `mouse click\|move\|scroll\|down\|up R,C`, `wait`, `settle` | -| Observe | `shot NAME [SEL]`, `tree [SEL]`, `dump [SEL]`, `hit R,C` | -| Assert | `expect text ROW "..."`, `expect screen <(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) - } -}