From 79da704329fa0fa94c2ab775e9b3bac5e6240e42 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Fri, 23 Oct 2026 17:16:30 -0400 Subject: [PATCH 01/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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 <" help="Write each scenario's trace, one JSON line per step, under this directory" #USAGE flag "--json" help="Print one JSON object even on a terminal" #USAGE flag "--pretty" help="Print the text form even when output is not a terminal" set -euo pipefail From b2c3161529b3baf473efa390de077356ea75cfaf Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 05:42:38 -0400 Subject: [PATCH 22/55] test(harness): bound the cost of a ForEach step in its scenario --- Examples/Scenarios/foreach-desktop.scn | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Examples/Scenarios/foreach-desktop.scn b/Examples/Scenarios/foreach-desktop.scn index 1e311b7..d47c31d 100644 --- a/Examples/Scenarios/foreach-desktop.scn +++ b/Examples/Scenarios/foreach-desktop.scn @@ -16,6 +16,10 @@ type "+" expect text 7 "d delta" expect node id=d frame 0,7,7x1 expect text 9 "4 items" +# One more item costs a few rows of bytes, not a redraw of the whole screen. The bounds are +# loose on purpose: they catch a change that makes the library do much more, and do not pin +# today's numbers. +expect cost bytes<=1000 renders<=24 placements<=48 check # Reversed, the same four items sit in the opposite order: `d` is first and `a` last. From 5b8b4c1620d64df424fbc00b0ab072d13fbb30f7 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 05:56:27 -0400 Subject: [PATCH 23/55] docs(plans): record Slice 6, the projection, rules, cost, trace, and the first demos --- ...hase-4-view-layer-and-developer-tooling.md | 20 +++++++++++++++++-- 1 file changed, 18 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 b390133..65e19ce 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -19,7 +19,7 @@ This plan merges plan 032 (Phases 8-17, its open implementation phases; Phases 1 - [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 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. - [x] 5 Scenario language, runner, the `tessera` CLI, and the CI corpus: `3faa2f4` and `ef4592b` on `phase-4/05-scenarios` (PR #66, stacked on #65); full `mise run core:test` runs 838 tests green on Xcode 26.6 and on Xcode 27.1 (750 on the base; 88 added across the parser, player, runner, golden, report, answerer, and command tests in `TesseraHarnessTests`), `swift test --package-path Examples` runs 25 tests on both toolchains (23 on the base; `ScenarioCorpusTests` added), and `mise run quality:format`, `quality:lint`, and `docs:lint` are green; neither `Package.resolved` changed. Two runs of `tessera run Examples/Scenarios` give the same bytes (`shasum` `55fa9588…` twice, 6,996 bytes of JSON); a parse error under `--json` is a report with `"verb":"parse"` and exit 2; a drifted golden fails without `--record` (exit 1, the first differing row named) and `--record` rewrites it (`(updated)`); and the local CI probe, with `counter-basics.scn`'s `expect text 2` changed to a wrong count, exits 1 with its failure at line 19 and exits 0 once reverted (reports below). CI on `ef4592b` (run 40074331235) and on the plan record `5b4dabf` (run 40075060791) passed all 14 jobs, and every `test` leg ran `tessera run --pretty Examples/Scenarios` and printed `5 scenarios, 5 passed, 0 failed`, `test (windows-latest)` included, with its goldens checked out by Git for Windows' defaults and LF by `.gitattributes`; the first run (`3faa2f4`, run 40073722211) failed only that leg, in the README table test, because a Windows checkout of `README.md` has CRLF endings and a `"\r\n"` is one `Character` (fixed in `ef4592b`). The seven findings of the critic review of PR #66 (the app finishing, case-folded shot names, a CRLF golden that had also drifted, `run --repeat PATH`, the unnamed JSON fields, the heredoc closer, and `check`'s app name) are addressed on top of it, and the PR carries the head's run. -- [ ] 6 Projection, locators, invariants, cost, trace, and shots, with the first demos +- [x] 6 Projection, locators, invariants, cost, trace, and shots, with the first demos: `6781c57` (the library: a resize's viewport reasons survive `update()`, and `TerminalSession.bytesDrawn`), `10f8484` (the projection, rules, `expect cost`, trace, grid goldens, and the six demos with their 12 scenarios), and `b2c3161` (an `expect cost` bound in the `ForEach` scenario) on `phase-4/06-projection` (PR #67, stacked on #66, merged with `origin/phase-4/05-scenarios` at `1cd10ea`); full `mise run core:test` runs 934 tests green on Xcode 26.6 and on Xcode 27.1 (838 on the base; 93 added in `TesseraHarnessTests` across the type-name, grid, projection, rule, statement, trace, replay, and command tests, and two `bytesDrawn` tests and one resize-reason test in the library), `swift test --package-path Examples` runs 36 tests on both toolchains (25 on the base; `PrimitiveDemoTests` added and the corpus floor raised to 16), and `mise run quality:format`, `quality:lint`, and `docs:lint` are green; neither `Package.resolved` changed, and `git diff --name-only origin/phase-4/05-scenarios...HEAD` lists none. `mise run dev:run Examples/Scenarios` runs 16 scenarios, 16 passed, and two runs give the same bytes (`shasum` `15ca662c…` twice, 30,265 bytes of JSON; `--repeat 3` agrees); `siblings-disjoint` passes on the `foreach` demo after items are added, removed, and reversed (`check` after each step of `foreach-desktop.scn`). `tessera check foreach` exits 0 and names its five rules; `tessera check workspace mobileScreen` exits 1 with `error measured-within-proposal root/index(2)/id(catalog)/index(0)/index(0): measured 7 columns where 0 were offered` (Discoveries); tightening `foreach-desktop.scn`'s `expect cost bytes<=1000` to `<=100` fails with `step 1 (key +) took 430 bytes, above the bound 100`. `tessera run --trace` writes five records for `foreach-desktop.scn`, and every redraw names its reasons (`layoutEnvironmentChanged`, `layoutViewChanged`, `renderEnvironmentChanged`, `renderRequested`, `renderViewChanged`, `updateRequested`), with 2,577 bytes for the first frame and 430, 81, 473, and 457 for the four steps after it. CI on `b2c3161` (run 40083068340) passed all 14 jobs, and every `test` leg printed `16 scenarios, 16 passed, 0 failed`, `test (windows-latest)` included, with the new grid goldens checked out as LF. - [ ] 7 Flow layout and `ViewThatFits` - [ ] 8 Styling and decoration, with pictures, roles, and catalog review - [ ] 9 Focus, key routing, and KeyLegend, with focus tooling @@ -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 (PR #60 is open). Slices 2, 3, 4, and 5 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`; PR #66, stacked on #65: the scenario language, the runner, `tessera run`, `shot`, and `tree`, and the CI corpus). Slice 6 follows Slice 5 and can branch from `phase-4/05-scenarios` until #59, #64, #65, and #66 merge; it replaces Slice 5's refusals of `dump`, `check`, `expect cost`, `tessera check`, and `tessera replay` as it builds their engines, and the Slice 3, 4, and 5 decisions marked "(owner to weigh)" are reversible and do not block it. Decision 9 (i) is recorded below, so nothing in Slices 6-7 waits on the owner. +Next: Slice 1 in its own pull request (PR #60 is open). Slices 2, 3, 4, 5, and 6 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`; PR #66, stacked on #65: the scenario language, the runner, `tessera run`, `shot`, and `tree`, and the CI corpus; PR #67, stacked on #66: the projection, locators, rules, `expect cost`, the trace, grid goldens, and the first demos). Slice 7 follows Slice 6 and needs the design catalog's PR #62 merged first; it can then branch from `phase-4/06-projection` until #59, #64, #65, #66, and #67 merge. The Slice 3-6 decisions marked "(owner to weigh)" are reversible and do not block it. Decision 9 (i) is recorded below, so nothing in Slice 7 waits on the owner. ## Approach @@ -283,6 +283,13 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-24 `--repeat N` records on its first run only and compares every later run's report (with `recorded` and `seconds` removed) with the first, so a divergence is a `repeat` failure, exit 1, and a nondeterministic app shows up at the golden as well. `--timing` adds `seconds`, one rounded number per run, the only field that differs between two runs. `ShotArtifact` and the spike's `--out` are gone: goldens are the artefact, and `.build/tessera/` stays unused until pictures exist. A `\xHH` escape in a double-quoted word is ASCII only (00-7f), so a scenario never builds invalid UTF-8; a zero-scenario directory or a path that is not a `.scn` file is a usage error, so a wrong path in CI cannot pass for a green run. (this plan, Slice 5 run) - 2026-10-24 CI runs `tessera run --pretty Examples/Scenarios` as a step of the `test` job, so every leg of its matrix runs it, the three required names (`macos-26`, `ubuntu-latest`, `windows-latest`) and the Swift 6.4 lanes included, and no new required check name is needed (Decision 7); `--pretty` keeps the log readable. `ci:build-test` runs the same line. (this plan, Slice 5 run) - 2026-10-24 Critic review of PR #66, decisions the findings settled (owner to weigh). A scenario ends when its app finishes (F1): `LiveHost` returns the moment `isFinished` is true, so the player does too. The statement that finishes the app is the last that runs and the rest of its input is dropped (`type "q+"` stops after `q`); any statement after that fails with its own verb (exit 1), with a message naming the line that finished the app and no `screen`, and the run stops there, so no `expect`, `shot`, or `tree` after a quit is evaluated; an app already finished at the start fails its first statement. The README says "any statement" and notes that statements asserting the finish itself may be added later, so a future `expect finished` is additive and not a contradiction. The final frame after a quit therefore cannot be asserted, only what the app showed before the statement that quit. Shot names are unique ignoring case (F2), a parse error naming both lines, because goldens are files and APFS and NTFS fold case. A golden containing a carriage return is a CRLF failure before any row is compared (F3). The `expect screen` closing line may carry trailing spaces or tabs, and nothing else (F6). `check` and `replay` validate their app name before they refuse (F7), so only the refusal changes when the rules exist. `--record` records every `shot` the run reaches, including after a failed expectation, and the run still fails; the README says so. The README and the `run` help now name every promised JSON field (F5): the `recorded` entry shape, `line` 0 with an empty `statement` for file- and command-level failures, `--repeat N` as a `repeat` failure's statement, and that a missing or CRLF golden carries no `expected`, `actual`, or `screen`. (this plan, Slice 5 run) +- 2026-10-24 Slice 6 builds the engines Slice 5 refused and removes the refusals (owner to weigh; this supersedes the Slice 5 entry on refusals). `dump`, `check`, `expect cost`, `tessera check`, and `tessera replay` run, and the README's not-yet-expressible table and its parity test now list only the forms that still wait: `wait`, `settle`, `focus`, `mouse`, `hit`, `expect cell`, `expect focused`, `expect wireframe`, `expect node SEL visible|clipped|count N`, `shot NAME SEL`, `tree SEL`, and the `term` keys `glyphs=`, `keyboard=`, and `theme=probe`, each a parse error that names what it waits for. `tessera check` keeps Slice 5's argument validation and exit codes. (this plan, Slice 6 run) +- 2026-10-24 Four of the five rules are errors and `frame-inside-clip` is a warning (owner to weigh). A bare `check` runs all five in a fixed order; an error fails the step (exit 1, the failure's `rule` and `node` fields name it), a warning is listed in the step's observation and does not fail it, and naming the rule (`check frame-inside-clip`) makes it strict. The library lets content leave its clip by contract (a fixed frame wider than its parent, a Flex child that overflows, `Text`, which does not wrap at HEAD), so the rule shows the overflow without failing every demo that has one. `siblings-disjoint` compares the cells each sibling shows (its frame intersected with its clip), not the frames: a Flex child clipped to its segment is not an overlap, and two children placed on one frame (the `ForEach` defect) are. `ZStack` is exempt from it as a container that overlaps by contract, and `frame-inside-clip` skips descendants of a `ScrollView`; a new overlay or scrolling container must join those two lists. A rule added after 1.0 runs when named and joins bare `check` at the next major version, so a release cannot fail an app's CI with a rule it never asked for. (this plan, Slice 6 run) +- 2026-10-24 `text=` belongs to a leaf by the cells it shows, not by frame containment, and `id=` takes the app's own id or else a full path (owner to weigh). `text="S"` matches the leaf whose own cells show S: a container has no text, and text that a later layer paints over matches nothing, because a cell belongs to the last leaf in paint order that covers it. `id=X` is the `X` of `.id(X)` or of a `ForEach` key, or else the node's full identity path; `layout=NAME` is the short algorithm name of a `_LayoutContainer` (`_FrameLayout`, `_PaddingLayout`, or an app's own `Layout` type). A selector that matches nothing, or several without `nth=`, fails and names what is present. (this plan, Slice 6 run) +- 2026-10-24 The trace is one sorted-key JSON object per step, and its switch is a flag (owner to weigh; the slice names the record and not the switch). `tessera run --trace DIR` writes `DIR/PATH.trace.jsonl`, mirroring the scenario's path with parent components dropped so a file stays inside DIR, and a live session writes the file `TESSERA_TRACE` names (exit 3 before the terminal changes mode when it cannot). A record has `version`, `seq` (0 is the first frame), the `event` (a key, paste, or resize in the scenario grammar's words, or `other`), `drew`, the invalidation `reasons`, the `nodes` added, changed, and removed, the screen `rows` that changed, and the `cost`. `tessera replay FILE --app NAME` runs the events again at the recorded size and compares reasons, nodes, rows, and cost but not bytes, which belong to the terminal; a trace holding an `other` event (a mouse event, a terminal reply) is a usage error (exit 2), since the grammar cannot say it yet. Neither the flag nor the record is promised: the record carries a `version` and may change in any release. (this plan, Slice 6 run) +- 2026-10-24 `expect cost NAME<=N…` bounds the last step the scenario took, and only with `<=` (owner to weigh). The counters are `bodies`, `bytes`, `measures`, `placements`, and `renders`; the step is the last event delivered, so the statement can follow a `shot` or an `expect`; a failure prints the whole record (`bodies=0 bytes=430 measures=143 placements=34 renders=18`). `bytes` is the size of the step's frame, read from a new `package` property, `TerminalSession.bytesDrawn`, which the session sets when a draw commits, so the harness needs no lock and no output tap. The counts are the library's work and not a promise, so the corpus bounds them loosely (`foreach-desktop.scn` allows 1,000 bytes for a step that takes 430). (this plan, Slice 6 run) +- 2026-10-24 `dump` and the grid golden (owner to weigh). `dump` puts the projection in the step's observation as a `projection` object, with `lines` empty; the projection is the graph's diagnostics as sorted-key JSON (per node: path, parent, children, depth, paint order, short `type` and `layout`, proposal, measured size, frame, clip, dirty flags, environment overrides, handler kinds, requirements, and the leaf's `text`), the statistics and invalidation reasons without durations, and the screen's rows and style runs, at `version` 1. `NAME.grid.txt` is the catalog's grid dialect over that screen: `── chars ──`, `── style ──` (a letter under each covered cell, `.` for the default, trailing default cells left off), and `── legend ──`; colours are `#rrggbb` or `indexed(N)`, and attributes are `bold`, `dim`, `italic`, `underline` (`underline=curly` and the other styles), `reverse`, and `strikethrough`. The virtual terminal exposes no wide flag, so a blank cell right after a width-2 grapheme is read as its continuation; Slice 8's Ghostty cell flag should replace that rule. (this plan, Slice 6 run) +- 2026-10-24 Per-primitive demos absorb Slice 5's `stack` demo and are written to be read at 40 columns (owner to weigh). `stacks` replaces `stack` (and `stack-mobile.scn` with its goldens) and shows stacks, `Spacer`, and `layoutPriority`; the other five are `text` (the archived Unicode samples, kept short), `frame-padding`, `zstack`, `flex`, and `foreach`. Each takes a desktop and a mobile scenario that shots both, asserts one frame, and mostly runs `check`; a demo that overflows on purpose (`frame-padding`, `flex`) names the rules it passes, and a demo shows every variable style it has (alignments, padding, priorities, weights). (this plan, Slice 6 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 @@ -324,6 +331,15 @@ Rules for an agent running slices of this plan without the owner. - The reports an agent reads, as `tessera run … > report.json` wrote them (rows elided). A pass, `Examples/Scenarios/counter-basics.scn`: `{"command":"run","exitCode":0,"scenarios":[{"failures":[],"observations":[{"line":26,"lines":["Counter","────…","Count … 0","────…","+ up - down 0 reset q quit"],"name":"reset","verb":"shot"}],"passed":true,"scenario":"Examples/Scenarios/counter-basics.scn"}]}`. The CI probe, the same file with `expect text 2` asking for a count of 4 where the screen shows 3, exit 1: `{"command":"run","exitCode":1,"scenarios":[{"failures":[{"actual":"Count … 3","expected":"Count … 4","line":19,"message":"row 2 text differs","screen":["Counter","…","Count … 3","…","+ up - down 0 reset q quit"],"statement":"expect text 2 \"Count … 4\"","verb":"expect text"}],"observations":[…],"passed":false,"scenario":"Examples/Scenarios/counter-basics.scn"}]}`. A parse error in `bad.scn`, exit 2: ``{"command":"run","exitCode":2,"scenarios":[{"failures":[{"line":3,"message":"unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node","statement":"blink 3","verb":"parse"}],"observations":[],"passed":false,"scenario":"bad.scn"}]}``. - Anything a test reads from a repository file and searches with Foundation sees the platform's line endings on Windows: `components(separatedBy: "\n## ")` found no section in a CRLF checkout of `README.md`, because "\r\n" is one `Character` and a search for "\n" does not match inside it, so the README table test (and any future test that reads a Markdown file) splits at the `\n` scalar, as the parser does. Only scenario files and goldens have a `.gitattributes` rule; the rest of the repository keeps Git for Windows' default. - Once the headless host switches to active detection, a probe round the answerer does not answer waits out `activeProbeTimeout` for every scenario. The answerer replies to colour queries and to DA1, so the colour round and every round that ends on a DA1 end at once; the DECRQM round ends on its DA1 only with the sentinel fix of PR #63, and without it the round would also need `CSI ? Ps ; 0 $ y` replies. Check this against the real session from Slice 1 before flipping the switch. +- A resize's viewport reasons were dropped by the next `update()`: `resize` recorded `layoutViewportChanged` and `renderViewportChanged`, and `update()` reset them before the pass that draws, so a resize redraw named `updateRequested` only. Fixed in `6781c57` (the graph keeps `viewportChangedSinceRender` until the next render), with a test in `DiagnosticsTests` and a line in the Slice 1 and Phase 5 specs. +- Every step of a demo built on stacks reports `layoutEnvironmentChanged` and `renderEnvironmentChanged`: stacks rebuild their children's `EnvironmentValues` on each update, and reconciliation compares environment storage by identity (`===`), so an environment that did not change reads as changed. The trace names it on every redraw of `stacks`, `flex`, and `foreach`, so no scenario can bound the reasons yet; the library should compare values or keep one storage across updates (owner to weigh). +- The headless virtual terminal lacks DEC mode 2027, so a ZWJ emoji, a regional-indicator pair, and a variation-selector-16 sequence advance a different number of cells than Tessera's width table says (`👩🏽‍💻` takes 6 cells in the terminal and measures 2), and Tessera neither enables mode 2027 nor repositions the cursor after a wide grapheme. The `text` demo keeps its Unicode lines short and puts the samples where a drift cannot shift a neighbour, and its goldens show the terminal's cells (owner to weigh). +- A `Spacer` with `layoutPriority(1)` in `HStack { L; Spacer.layoutPriority(1); M; Spacer; R }` takes all the remaining width and places `M` and `R` past the edge, because a higher tier allocates first (as the spec says) and a `Spacer`'s ideal is unbounded; the `stacks` demo also gives priority to its neighbours so every row fits (owner to weigh). +- `Flex`'s `min(n)` floor cannot compress a `Text` child, since `Text`'s minimum equals its ideal while it does not wrap (Slice 8), and when the children are larger than the space the fill weights invert (owner to weigh); the `flex` demo asserts today's frames. +- `measured-within-proposal` and `frame-inside-clip` fire by contract on a fixed frame wider than its parent, on a Flex child that overflows, and on `Text` that does not wrap, which is why `frame-padding` and `flex` name the rules they pass. The `workspace` demo at `mobileScreen` (40x16) fails `measured-within-proposal` on a collapsed pane's `Text`: `tessera check workspace mobileScreen` exits 1 with `measured 7 columns where 0 were offered` at `root/index(2)/id(catalog)/index(0)/index(0)` until Slice 8 wraps `Text`. +- As Slice 4 found, a graph rooted at an `@ViewBuilder` body is dirty after every `update()`, so every step of every demo draws (`drew: true`); the cost counters show it, and a step that changes nothing still writes bytes. `no-dirty-after-pass` passes on the corpus because a completed pass clears every flag, and `ChecksTests` crafts a projection that still has a dirty node to prove it fails. The fixtures in `ProjectionFixtures` with an explicit root skip draws (`drew: false`), which is how a clean step's trace record and cost are tested. +- No view in the corpus sets a terminal requirement, so `requirements-aggregate` passes vacuously there; its tests use crafted projections, and Slice 10's pointer demo (any-event tracking requested only while a handler needs it) is its first live subject. +- Tooling: `mise run quality:format` and `quality:lint` skip `Examples/Tests`, but the pre-commit hook lints every staged Swift file, so format that directory by hand (`scripts/swift-format.sh format --configuration .swift-format -i` and `swiftlint lint --fix`) before committing. A `--scratch-path .build-27` directory is untracked and not ignored, so `git add -A` stages SwiftPM's checkouts as embedded repositories and the hook's stash of untracked files timed out on it; delete the directory before staging. `swift run --package-path Examples` without `--only-use-versions-from-resolved-file` rewrites `Examples/Package.resolved` (the mise tasks pass the flag). ## Outcome From a7260a779beca334a990d5fb3f820a2aecb843e3 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 06:48:17 -0400 Subject: [PATCH 24/55] fix(harness): address the critic review of the projection, grid, and demos siblings-disjoint looks through ForEach, if, and view groups to the container that places them, so layers built by a list under a ZStack no longer fail the rule. The grid golden now keeps an empty chars row for each styled blank row, so it never has more style rows than chars rows, and a screen with more than 62 distinct styles fails the shot as a harness error instead of writing a repeated letter. run --pretty prints a bare check's warnings, an empty path, name, or flag value is a usage error, an empty locator value is a parse error, the trace version is read before its schema, and a live trace records the terminal's focus changes in a form replay delivers. The Flex demo gains a second page (a layoutPriority tier, a child with no flex value, cross-axis placement), the Stacks demo a Spacer(minLength:) and a Spacer in a VStack, and the ZStack demo layers from a ForEach and an if. text.md says what the Text demo renders today. --- .agents/skills/tessera-devtools/SKILL.md | 6 +- CHANGELOG.md | 6 +- Examples/Scenarios/README.md | 10 +- Examples/Scenarios/flex-desktop.scn | 30 +++- .../Scenarios/flex-desktop/desktop.grid.txt | 4 +- Examples/Scenarios/flex-desktop/desktop.txt | 4 +- .../Scenarios/flex-desktop/variants.grid.txt | 23 +++ Examples/Scenarios/flex-desktop/variants.txt | 19 +++ .../flex-desktop/weights-1-2.grid.txt | 4 +- .../Scenarios/flex-desktop/weights-1-2.txt | 4 +- Examples/Scenarios/flex-mobile.scn | 17 ++- .../Scenarios/flex-mobile/mobile.grid.txt | 4 +- Examples/Scenarios/flex-mobile/mobile.txt | 4 +- .../Scenarios/flex-mobile/spacing-0.grid.txt | 4 +- Examples/Scenarios/flex-mobile/spacing-0.txt | 4 +- .../Scenarios/flex-mobile/variants.grid.txt | 23 +++ Examples/Scenarios/flex-mobile/variants.txt | 19 +++ Examples/Scenarios/foreach-desktop.scn | 5 +- Examples/Scenarios/stacks-desktop.scn | 17 ++- .../Scenarios/stacks-desktop/desktop.grid.txt | 8 +- Examples/Scenarios/stacks-desktop/desktop.txt | 8 +- .../stacks-desktop/rows-apart.grid.txt | 8 +- .../Scenarios/stacks-desktop/rows-apart.txt | 8 +- Examples/Scenarios/stacks-mobile.scn | 19 ++- .../Scenarios/stacks-mobile/mobile.grid.txt | 8 +- Examples/Scenarios/stacks-mobile/mobile.txt | 8 +- Examples/Scenarios/zstack-desktop.scn | 17 ++- .../Scenarios/zstack-desktop/desktop.grid.txt | 6 +- Examples/Scenarios/zstack-desktop/desktop.txt | 6 +- .../Scenarios/zstack-desktop/grouped.grid.txt | 15 ++ Examples/Scenarios/zstack-desktop/grouped.txt | 11 ++ .../zstack-desktop/swapped-center.grid.txt | 6 +- .../zstack-desktop/swapped-center.txt | 6 +- Examples/Scenarios/zstack-mobile.scn | 12 ++ .../alignment-bottom-trailing.grid.txt | 6 +- .../alignment-bottom-trailing.txt | 6 +- .../Scenarios/zstack-mobile/grouped.grid.txt | 15 ++ Examples/Scenarios/zstack-mobile/grouped.txt | 11 ++ .../Scenarios/zstack-mobile/mobile.grid.txt | 6 +- Examples/Scenarios/zstack-mobile/mobile.txt | 6 +- Examples/Sources/DemoSupport/FlexDemo.swift | 128 +++++++++++------ .../Sources/DemoSupport/ForEachDemo.swift | 7 +- Examples/Sources/DemoSupport/StacksDemo.swift | 34 ++++- Examples/Sources/DemoSupport/ZStackDemo.swift | 28 +++- .../DemoSupportTests/PrimitiveDemoTests.swift | 72 +++++++++- .../TesseraHarness/HarnessCLI+Scenarios.swift | 6 + Sources/TesseraHarness/HelpText.swift | 21 +-- .../TesseraHarness/Projection/Checks.swift | 26 +++- .../Projection/ScreenGrid.swift | 119 +++++++++------- .../TesseraHarness/Scenarios/Goldens.swift | 11 +- .../TesseraHarness/Scenarios/Locator.swift | 8 +- Sources/TesseraHarness/Scenarios/Report.swift | 12 +- Sources/TesseraHarness/Trace/Trace.swift | 20 ++- Sources/TesseraHarness/Trace/TraceFile.swift | 22 ++- Tests/TesseraHarnessTests/ChecksTests.swift | 110 +++++++++++++++ .../HarnessCLIScenarioTests.swift | 84 +++++++++++ .../ProjectionFixtures.swift | 40 ++++++ Tests/TesseraHarnessTests/ReplayTests.swift | 22 ++- .../ScenarioStatementsTests.swift | 49 ++++++- .../TesseraHarnessTests/ScreenGridTests.swift | 131 +++++++++++++++++- Tests/TesseraHarnessTests/TraceTests.swift | 50 ++++++- docs/architecture/developer-tooling.md | 8 +- docs/design/primitives/text.md | 2 +- 63 files changed, 1176 insertions(+), 237 deletions(-) create mode 100644 Examples/Scenarios/flex-desktop/variants.grid.txt create mode 100644 Examples/Scenarios/flex-desktop/variants.txt create mode 100644 Examples/Scenarios/flex-mobile/variants.grid.txt create mode 100644 Examples/Scenarios/flex-mobile/variants.txt create mode 100644 Examples/Scenarios/zstack-desktop/grouped.grid.txt create mode 100644 Examples/Scenarios/zstack-desktop/grouped.txt create mode 100644 Examples/Scenarios/zstack-mobile/grouped.grid.txt create mode 100644 Examples/Scenarios/zstack-mobile/grouped.txt diff --git a/.agents/skills/tessera-devtools/SKILL.md b/.agents/skills/tessera-devtools/SKILL.md index 0efd9b6..11cea92 100644 --- a/.agents/skills/tessera-devtools/SKILL.md +++ b/.agents/skills/tessera-devtools/SKILL.md @@ -53,7 +53,7 @@ A scenario is `Examples/Scenarios/NAME.scn`: an `app`, a `size`, an optional `te 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 <] = [] @@ -45,6 +47,7 @@ final class StacksDemo: Demo { beside(.top, "top") beside(.center, "center") beside(.bottom, "bottom") + between() } Divider() Text("one Spacer") @@ -61,6 +64,14 @@ final class StacksDemo: Demo { Spacer() Text("R") } + Text("Spacer(minLength: 20), Spacer()") + HStack(spacing: 0) { + Text("L") + Spacer(minLength: 20) + Text("M") + Spacer() + Text("R") + } Text("priority 1: first Spacer and neighbours") HStack(spacing: 0) { Text("L").layoutPriority(1) @@ -116,4 +127,17 @@ final class StacksDemo: Demo { } } } + + /// A `Spacer` in a `VStack`: it takes the rows left in a three-row frame between two lines. + private func between() -> some View { + VStack(alignment: .leading, spacing: 0) { + Text("Spacer") + VStack(spacing: 0) { + Text("A") + Spacer() + Text("B") + } + .frame(height: 3) + } + } } diff --git a/Examples/Sources/DemoSupport/ZStackDemo.swift b/Examples/Sources/DemoSupport/ZStackDemo.swift index 43715dd..7fb4baa 100644 --- a/Examples/Sources/DemoSupport/ZStackDemo.swift +++ b/Examples/Sources/DemoSupport/ZStackDemo.swift @@ -6,8 +6,10 @@ import TesseraHarness /// The first sample is the catalog's anatomy, `Text("OK")` over `Text("status")`. The second /// places a `Text("Go")` in an 8x3 backdrop of dots with the alignment `a` cycles through. The /// third overlaps two blocks, and `s` swaps their source order: the later layer is the one -/// that shows. The last two show an empty `ZStack`, which reports no size and paints nothing, -/// and a `layoutPriority` that a `ZStack` ignores. `q` quits. +/// that shows. The last four show an empty `ZStack`, which reports no size and paints nothing, +/// a `layoutPriority` that a `ZStack` ignores, layers built by a `ForEach`, and a group of +/// layers that `g` adds with an `if`; the layers of a list are still layers, so the later +/// one shows. `q` quits. final class ZStackDemo: Demo { static let name = "zstack" static let minimum = TerminalSize(columns: 40, rows: 11) @@ -24,8 +26,11 @@ final class ZStackDemo: Demo { private static let backdrop = Array(repeating: "········", count: 3).joined( separator: "\n") + private static let layers = ["abcd", "ef"] + private var alignmentIndex = 0 private var swapped = false + private var grouped = false private var quitRequested = false var isFinished: Bool { @@ -81,10 +86,26 @@ final class ZStackDemo: Demo { Text("B") } } + sample("layers") { + ZStack { + ForEach(Self.layers, id: \.self) { layer in + Text(layer) + } + } + } + sample("group") { + ZStack { + Text("base") + if grouped { + Text("g") + Text("h") + } + } + } } Divider() Text("alignment \(alignment.name), \(swapped ? "A on top" : "bb on top")") - Text("a alignment s swap order q quit") + Text("a alignment s swap g group q quit") } } @@ -104,6 +125,7 @@ final class ZStackDemo: Demo { switch key.code { case .character("a"): alignmentIndex = (alignmentIndex + 1) % Self.alignments.count case .character("s"): swapped.toggle() + case .character("g"): grouped.toggle() default: break } } diff --git a/Examples/Tests/DemoSupportTests/PrimitiveDemoTests.swift b/Examples/Tests/DemoSupportTests/PrimitiveDemoTests.swift index e13b15e..dfe0d24 100644 --- a/Examples/Tests/DemoSupportTests/PrimitiveDemoTests.swift +++ b/Examples/Tests/DemoSupportTests/PrimitiveDemoTests.swift @@ -156,16 +156,82 @@ struct PrimitiveDemoTests { } } + @Test + func `layers a ForEach and an if group build are layers that check does not flag`() + async throws + { + try await Demos.registry().withHeadlessHost("zstack", at: .desktop) { host in + // The two ForEach items share one frame under the ZStack, and `efcd` is that overlap. + let list = try #require(host.projection.nodes.first { $0.type == "ForEach" }) + let frames = list.children.compactMap { host.projection.node(at: $0)?.frame } + #expect(frames.count == 2) + #expect(frames[0].column == frames[1].column && frames[0].row == frames[1].row) + #expect(host.screenRows[7].hasSuffix("efcd base")) + #expect(Checker.run(CheckRule.allCases, on: host.projection).isEmpty) + + // `g` adds an `if` group of two texts on one frame, over `base`. + try await host.send(key("g")) + #expect(host.screenRows[7].hasSuffix("efcd hase")) + let group = host.projection.nodes.last { $0.type == "TupleView" } + #expect(group?.children.count == 2) + #expect(Checker.run(CheckRule.allCases, on: host.projection).isEmpty) + } + } + @Test func `the spacers of the stacks demo share what is left by the rules of the catalog`() async throws { + // The vertical Spacer is 0 columns wide. Then one spacer takes 78 cells, two share 77 as 39 + // and 38, `minLength: 20` changes nothing at 80 columns, and the priority spacer takes 77 + // while the unprioritised one gets nothing. try await Demos.registry().withHeadlessHost("stacks", at: .desktop) { host in let spacers = host.projection.nodes.filter { $0.type == "Spacer" }.map( \.frame.columns) - // One spacer takes 78 cells, two share 77 as 39 and 38, and the priority spacer takes - // 77 while the unprioritised one gets nothing. - #expect(spacers == [78, 39, 38, 77, 0]) + #expect(spacers == [0, 78, 39, 38, 39, 38, 77, 0]) + } + // At 40 columns the share would be 19, so the floor of 20 binds and the plain Spacer is + // left 17. + try await Demos.registry().withHeadlessHost("stacks", at: .mobile) { host in + let spacers = host.projection.nodes.filter { $0.type == "Spacer" }.map( + \.frame.columns) + #expect(spacers == [0, 38, 19, 18, 20, 17, 37, 0]) + } + } + + @Test + func `a spacer in a vstack takes the rows between its neighbours`() async throws { + try await Demos.registry().withHeadlessHost("stacks", at: .desktop) { host in + let vertical = host.projection.nodes.first { $0.type == "Spacer" } + #expect(vertical?.frame == Projection.Box(column: 24, row: 9, columns: 0, rows: 1)) + #expect(host.screenRows[8].hasSuffix("A")) + #expect(host.screenRows[10].hasSuffix("B")) + } + } + + @Test + func `flex page two shows a priority tier, an unflexed child, and the cross axis`() + async throws + { + try await Demos.registry().withHeadlessHost("flex", at: .desktop) { host in + try await host.send(key("n")) + let frames = Dictionary( + host.projection.nodes.compactMap { node in node.text.map { ($0, node.frame) } } + ) { first, _ in first } + #expect(host.screenRows[0] == "Flex: priority, defaults, cross axis") + // Three equal fills split 75 cells; with a priority tier the third takes them all. + #expect(frames["E"]?.column == 27) + #expect(frames["H"]?.column == 2) + #expect(frames["I"]?.column == 4) + // No flex value is weight 1 beside a fill(1): 38 cells each, the odd one to the first. + #expect(frames["K"]?.column == 41) + // A child smaller than the cross axis sits at the leading edge. + #expect(frames["•"] == Projection.Box(column: 2, row: 9, columns: 1, rows: 1)) + #expect(frames["◦"]?.column == 0) + let violations = Checker.run(CheckRule.allCases, on: host.projection) + #expect(violations.isEmpty, "\(violations.map(\.text))") + try await host.send(key("n")) + #expect(host.screenRows[0] == "Flex: constraints") } } diff --git a/Sources/TesseraHarness/HarnessCLI+Scenarios.swift b/Sources/TesseraHarness/HarnessCLI+Scenarios.swift index 0e65baf..20f7370 100644 --- a/Sources/TesseraHarness/HarnessCLI+Scenarios.swift +++ b/Sources/TesseraHarness/HarnessCLI+Scenarios.swift @@ -27,6 +27,10 @@ struct CommandArguments { guard let value = iterator.next() else { throw Failure(message: "`\(argument)` needs a value") } + // An empty path would join to the filesystem root, and an empty name matches nothing. + guard !value.isEmpty else { + throw Failure(message: "`\(argument)` needs a value that is not empty") + } guard values.updateValue(value, forKey: argument) == nil else { throw Failure(message: "`\(argument)` is given twice") } @@ -35,6 +39,8 @@ struct CommandArguments { throw Failure( message: "unknown flag `\(argument)`; accepted: \(accepted.joined(separator: ", "))") + } else if argument.isEmpty { + throw Failure(message: "an empty argument is not a path or an application name") } else { positional.append(argument) } diff --git a/Sources/TesseraHarness/HelpText.swift b/Sources/TesseraHarness/HelpText.swift index ea718eb..13486dc 100644 --- a/Sources/TesseraHarness/HelpText.swift +++ b/Sources/TesseraHarness/HelpText.swift @@ -4,14 +4,14 @@ enum HelpText { 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 [SIZE] check one application against the invariant rules - replay FILE rerun a recorded trace against an application - help [COMMAND] describe a command + 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 [SIZE] check one application against the invariant rules + replay FILE --app NAME rerun a recorded trace against an application + 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 @@ -115,8 +115,9 @@ enum HelpText { recording started at. After every step the replay is compared with the recording on why the frame was drawn, the nodes and rows that changed, and the cost, which are the same on any terminal; the bytes a frame wrote are not compared. A step that differs is a - `replay` failure (exit 1) that shows both. A trace that holds an event the scenario - grammar cannot say yet, or that is not a trace, is a usage error (exit 2). The output is + `replay` failure (exit 1) that shows both. Keys, pastes, resizes, and the terminal's focus + changes replay; a trace that holds any other event (a mouse event, a terminal reply), or + that is not a trace, is a usage error (exit 2). The output is a run report, {"command": "replay", "exitCode", "scenarios": [REPORT]}, whose observation holds the final screen. """, diff --git a/Sources/TesseraHarness/Projection/Checks.swift b/Sources/TesseraHarness/Projection/Checks.swift index 6dff965..822e39f 100644 --- a/Sources/TesseraHarness/Projection/Checks.swift +++ b/Sources/TesseraHarness/Projection/Checks.swift @@ -88,6 +88,12 @@ private let overlappingContainers: Set = ["ZStack"] /// container doing its job. private let scrollingContainers: Set = ["ScrollView"] +/// The node types that hold a view list, which layout looks through: it places a list's +/// elements as though they were children of the list's own parent. +private let listTypes: Set = [ + "ConditionalView", "EmptyView", "ForEach", "Optional", "TupleView", +] + /// One projection, indexed for the rules. private struct CheckContext { let projection: Projection @@ -106,6 +112,18 @@ private struct CheckContext { detail: detail) } + /// The node whose layout places `node`'s children: `node` itself, or the nearest ancestor + /// that is not a view list when `node` is one. + func placingContainer(of node: Projection.Node) -> Projection.Node { + var current = node + while listTypes.contains(current.type), + let parent = current.parent.flatMap({ byPath[$0] }) + { + current = parent + } + return current + } + /// A node still marked for layout or rendering once the pass that drew the screen is over. func noDirtyAfterPass() -> [Violation] { projection.nodes.compactMap { node in @@ -172,12 +190,16 @@ private struct CheckContext { /// /// A child shows its frame clipped to its clip, so a child wider than its segment that is /// clipped to it (a `Flex` child whose content overflows) does not overlap its neighbour; - /// two children placed on one frame (the `ForEach` defect) do. + /// two children placed on one frame (the `ForEach` defect) do. Layout looks through a view + /// list (`ForEach`, `if`, a group of views) to its elements, so the container that decides + /// whether they overlap is the nearest ancestor that is not a list: the elements of a + /// `ForEach` under a `ZStack` are layers, and under a `VStack` they are not. func siblingsDisjoint() -> [Violation] { var violations: [Violation] = [] for parent in projection.nodes where parent.children.count > 1 { let name = parent.layout ?? parent.type - guard !overlappingContainers.contains(name) else { + let container = placingContainer(of: parent) + guard !overlappingContainers.contains(container.layout ?? container.type) else { continue } let children = parent.children.compactMap { byPath[$0] } diff --git a/Sources/TesseraHarness/Projection/ScreenGrid.swift b/Sources/TesseraHarness/Projection/ScreenGrid.swift index 2c684f0..396a902 100644 --- a/Sources/TesseraHarness/Projection/ScreenGrid.swift +++ b/Sources/TesseraHarness/Projection/ScreenGrid.swift @@ -24,6 +24,18 @@ package struct ScreenGrid: Equatable, Sendable { var style: String } + /// A screen that cannot be written as a grid. + package struct GridError: Error, Equatable, CustomStringConvertible { + /// How many distinct styles the screen has. + var styles: Int + + package var description: String { + "the screen has \(styles) distinct styles, and a grid golden names at most " + + "\(ScreenGrid.alphabet.count) with one letter each; take the shot where fewer " + + "styles show, or compare the screen with `expect text` instead" + } + } + /// The style letters, in the order a grid hands them out. private static let alphabet = Array( "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") @@ -68,49 +80,6 @@ package struct ScreenGrid: Equatable, Sendable { return runs } - /// The grid as `NAME.grid.txt` holds it: the characters, an aligned row of style letters - /// for every character row, and the legend that names each letter. - /// - /// This is the catalog's styled-grid dialect (`docs/design.md`, "Styled grids"): three - /// sections in this order, one style letter per cell, `.` for the default style. Character - /// rows lose their trailing spaces and the grid loses its trailing empty rows, exactly as - /// `NAME.txt` does; a style row loses its trailing default cells, so a screen with no styled - /// cell has no style rows at all. A colour that no semantic role owns is spelled `#rrggbb` - /// or `indexed(N)`. - package var styledGridText: String { - var letters: [String: Character] = [:] - var order: [String] = [] - var styleRows: [String] = [] - for cells in rows { - var line = "" - for style in Self.columnStyles(of: cells) { - if style.isEmpty { - line.append(".") - continue - } - if letters[style] == nil { - letters[style] = Self.letter(at: order.count) - order.append(style) - } - line.append(letters[style] ?? "?") - } - while line.last == "." { - line.removeLast() - } - styleRows.append(line) - } - - var lines = ["── chars ──"] + textRows.dropTrailingEmptyRows() - lines.append("── style ──") - lines += styleRows.dropTrailingEmptyRows() - lines.append("── legend ──") - lines.append(". default") - for style in order { - lines.append("\(letters[style] ?? "?") \(style)") - } - return lines.joined(separator: "\n") + "\n" - } - package init(_ snapshot: ScreenSnapshot) { rows = snapshot.cells.map(Self.cells(of:)) cursor = snapshot.cursor @@ -172,10 +141,6 @@ package struct ScreenGrid: Equatable, Sendable { return result } - private static func letter(at index: Int) -> Character { - alphabet.indices.contains(index) ? alphabet[index] : "?" - } - /// The style of every column of a row; a continuation cell takes the style of the wide /// grapheme it continues, so a style letter sits under every cell the grapheme covers. private static func columnStyles(of cells: [GridCell]) -> [String] { @@ -207,6 +172,66 @@ package struct ScreenGrid: Equatable, Sendable { return digits.count == 1 ? "0" + digits : digits } + /// The grid as `NAME.grid.txt` holds it: the characters, an aligned row of style letters + /// for every character row, and the legend that names each letter. + /// + /// This is the catalog's styled-grid dialect (`docs/design.md`, "Styled grids"): three + /// sections in this order, one style letter per cell, `.` for the default style. The rows + /// follow one rule, so the dialect's "no more style rows than chars rows" always holds: a + /// character row loses its trailing spaces, the chars section ends at the last row that has + /// text or a style (a blank row with a background keeps its place as an empty line, and + /// `NAME.txt` still ends at the last row with text), and the style section ends at the last + /// row that has a style, each style row losing its trailing default cells. A screen with no + /// styled cell has no style rows at all. The dialect names a colour only by role, so a + /// colour that no role owns is spelled `#rrggbb` or `indexed(N)`, which + /// `docs/architecture/developer-tooling.md` ("Shots and goldens") fixes. + /// + /// - Throws: ``GridError`` when the screen has more distinct styles than the 62 letters + /// name, since a letter that stands for two styles would make the golden ambiguous. + package func styledGridText() throws(GridError) -> String { + let styled = rows.map(Self.columnStyles(of:)) + let distinct = Set(styled.joined().filter { !$0.isEmpty }) + guard distinct.count <= Self.alphabet.count else { + throw GridError(styles: distinct.count) + } + + var letters: [String: Character] = [:] + var order: [String] = [] + var styleRows: [String] = [] + for styles in styled { + var line = "" + for style in styles { + if style.isEmpty { + line.append(".") + continue + } + let letter = letters[style] ?? Self.alphabet[order.count] + if letters[style] == nil { + letters[style] = letter + order.append(style) + } + line.append(letter) + } + while line.last == "." { + line.removeLast() + } + styleRows.append(line) + } + + let text = textRows + let styledRows = styleRows.lastIndex { !$0.isEmpty }.map { $0 + 1 } ?? 0 + let shownRows = max(text.dropTrailingEmptyRows().count, styledRows) + var lines = ["── chars ──"] + text.prefix(shownRows) + lines.append("── style ──") + lines += styleRows.prefix(styledRows) + lines.append("── legend ──") + lines.append(". default") + for (index, style) in order.enumerated() { + lines.append("\(Self.alphabet[index]) \(style)") + } + return lines.joined(separator: "\n") + "\n" + } + /// The text of one row, without its trailing spaces. func text(ofRow row: Int) -> String { guard rows.indices.contains(row) else { diff --git a/Sources/TesseraHarness/Scenarios/Goldens.swift b/Sources/TesseraHarness/Scenarios/Goldens.swift index e473938..7288193 100644 --- a/Sources/TesseraHarness/Scenarios/Goldens.swift +++ b/Sources/TesseraHarness/Scenarios/Goldens.swift @@ -6,7 +6,7 @@ import Foundation /// /// `NAME.txt` 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. `NAME.grid.txt` is the same -/// screen as the catalog's styled grid (``ScreenGrid/styledGridText``): the characters, one +/// screen as the catalog's styled grid (``ScreenGrid/styledGridText()``): the characters, one /// style letter per cell, and the legend. Comparison is byte for byte, so drift in either file /// fails the `shot` statement; recording rewrites the files and says which changed. struct GoldenStore: Equatable, Sendable { @@ -60,9 +60,16 @@ struct GoldenStore: Equatable, Sendable { /// The text golden is compared first, and the first golden that differs or is missing is /// the one reported. func check(shot name: String, screen: ScreenGrid) -> Outcome { + let grid: String + do { + grid = try screen.styledGridText() + } catch { + return .failed( + message: "shot `\(name)` cannot be compared with a grid golden: \(error)") + } let files = [ (path: path(for: name), text: Self.text(of: screen.textRows)), - (path: gridPath(for: name), text: screen.styledGridText), + (path: gridPath(for: name), text: grid), ] if record { return write(files) diff --git a/Sources/TesseraHarness/Scenarios/Locator.swift b/Sources/TesseraHarness/Scenarios/Locator.swift index ba50eec..3d3622e 100644 --- a/Sources/TesseraHarness/Scenarios/Locator.swift +++ b/Sources/TesseraHarness/Scenarios/Locator.swift @@ -42,6 +42,11 @@ package struct Locator: Equatable, Sendable { } let key = String(token[.. Resolution { let matches = projection.nodes.filter { node in if let type, node.type != type { diff --git a/Sources/TesseraHarness/Scenarios/Report.swift b/Sources/TesseraHarness/Scenarios/Report.swift index a72c4ca..43400a6 100644 --- a/Sources/TesseraHarness/Scenarios/Report.swift +++ b/Sources/TesseraHarness/Scenarios/Report.swift @@ -110,8 +110,9 @@ public struct ScenarioReport: CustomStringConvertible, Equatable, Sendable { 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. + /// The human form: `PASS` or `FAIL` and the scenario's path, then the warnings a bare + /// `check` listed, 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. @@ -120,6 +121,13 @@ public struct ScenarioReport: CustomStringConvertible, Equatable, Sendable { for change in recorded { lines.append(" recorded \(change.path) (\(change.change.rawValue))") } + // A warning does not fail the scenario, so a person who reads only this form would never + // see it; the JSON form carries it in the observation. + for observation in observations where observation.verb == "check" { + for warning in observation.lines where warning.hasPrefix("warning ") { + lines.append(" line \(observation.line): \(warning)") + } + } for failure in failures { lines += humanLines(for: failure) } diff --git a/Sources/TesseraHarness/Trace/Trace.swift b/Sources/TesseraHarness/Trace/Trace.swift index 0196188..6395836 100644 --- a/Sources/TesseraHarness/Trace/Trace.swift +++ b/Sources/TesseraHarness/Trace/Trace.swift @@ -47,11 +47,13 @@ package struct StepCost: Equatable, Codable, Sendable { /// The event a step delivered, in the words the scenario grammar uses. /// -/// An event the grammar cannot say (a mouse event before pointer input exists, a terminal -/// reply) is recorded as `other`, so the trace stays complete and `replay` can refuse it by -/// name. +/// A key, a paste, and a resize are the grammar's own words. A focus change of the terminal +/// window has no statement yet, but a live session receives it, so it is recorded as +/// `focusGained` or `focusLost` and replays like the others. An event neither the grammar nor +/// replay can deliver (a mouse event before pointer input exists, a terminal reply) is +/// recorded as `other`, so the trace stays complete and `replay` can refuse it by name. package struct TraceEvent: Equatable, Codable, Sendable { - /// `key`, `paste`, `resize`, or `other`. + /// `key`, `paste`, `resize`, `focusGained`, `focusLost`, or `other`. package var type: String /// For a key, its name as a `key` statement spells it. @@ -89,6 +91,10 @@ package struct TraceEvent: Equatable, Codable, Sendable { return size.flatMap { ScenarioParser.parseDimensions($0) }.map { .resize(TerminalSize(columns: $0.0, rows: $0.1)) } + case "focusGained": + return .focusGained + case "focusLost": + return .focusLost default: return nil } @@ -100,6 +106,8 @@ package struct TraceEvent: Equatable, Codable, Sendable { case "key": "key \(key ?? "?")\(kind.map { " (\($0))" } ?? "")" case "paste": "paste \(text.map { "\"\($0)\"" } ?? "")" case "resize": "resize \(size ?? "?")" + case "focusGained": "focus gained" + case "focusLost": "focus lost" default: "\(detail ?? "other") (not replayable)" } } @@ -126,9 +134,9 @@ package struct TraceEvent: Equatable, Codable, Sendable { type = "resize" self.size = "\(size.columns)x\(size.rows)" case .focusGained: - detail = "focusGained" + type = "focusGained" case .focusLost: - detail = "focusLost" + type = "focusLost" case .mouse: detail = "mouse" case .kittyGraphicsResponse, .kittyKeyboardEnhancementFlags, .primaryDeviceAttributes, diff --git a/Sources/TesseraHarness/Trace/TraceFile.swift b/Sources/TesseraHarness/Trace/TraceFile.swift index 821dcf7..22144c9 100644 --- a/Sources/TesseraHarness/Trace/TraceFile.swift +++ b/Sources/TesseraHarness/Trace/TraceFile.swift @@ -6,6 +6,11 @@ import Foundation /// `tessera run --trace DIR` writes one file for each scenario, a live session writes the file /// `TESSERA_TRACE` names, and `tessera replay` reads either. enum TraceFile { + /// The one field every schema version of a record keeps, read before the rest. + private struct VersionOnly: Decodable { + var version: Int + } + /// A line of a trace file that cannot be read back. struct ReadError: Error, Equatable, CustomStringConvertible { /// The line's number, counted from 1. @@ -59,20 +64,29 @@ enum TraceFile { if trimmed.isEmpty { continue } - let record: TraceRecord + // The version first: a later schema may rename or drop a key, and that line should say + // which version it is, not that a key is missing. + let version: Int do { - record = try decoder.decode(TraceRecord.self, from: Data(trimmed.utf8)) + version = try decoder.decode(VersionOnly.self, from: Data(trimmed.utf8)).version } catch { throw ReadError( line: number, message: "this line is not a trace record (\(describe(error)))") } - guard record.version == TraceRecord.currentVersion else { + guard version == TraceRecord.currentVersion else { throw ReadError( line: number, message: - "the record has version \(record.version); this harness reads version " + "the record has version \(version); this harness reads version " + "\(TraceRecord.currentVersion)") } + let record: TraceRecord + do { + record = try decoder.decode(TraceRecord.self, from: Data(trimmed.utf8)) + } catch { + throw ReadError( + line: number, message: "this line is not a trace record (\(describe(error)))") + } records.append(record) } return records diff --git a/Tests/TesseraHarnessTests/ChecksTests.swift b/Tests/TesseraHarnessTests/ChecksTests.swift index dc3712f..1c345c4 100644 --- a/Tests/TesseraHarnessTests/ChecksTests.swift +++ b/Tests/TesseraHarnessTests/ChecksTests.swift @@ -217,6 +217,116 @@ struct ChecksTests { #expect(found.isEmpty) } + @Test + func `a ForEach's items are layers under a ZStack and overlap under a VStack`() { + func items(under container: String) -> [Violation] { + run( + .siblingsDisjoint, + projection([ + node( + "root", children: ["root/index(0)"], type: container, frame: (0, 0, 4, 2)), + node( + "root/index(0)", parent: "root", + children: ["root/index(0)/id(a)", "root/index(0)/id(b)"], type: "ForEach", + frame: (0, 0, 4, 1)), + node("root/index(0)/id(a)", parent: "root/index(0)", frame: (0, 0, 4, 1)), + node("root/index(0)/id(b)", parent: "root/index(0)", frame: (0, 0, 2, 1)), + ])) + } + + #expect(items(under: "ZStack").isEmpty) + #expect(items(under: "VStack").map(\.node) == ["root/index(0)/id(b)"]) + #expect(items(under: "VStack").first?.detail.hasSuffix("under ForEach") == true) + } + + @Test + func `the group of an if under a ZStack is looked through to the ZStack`() { + // `ZStack { Text("base"); if shown { Text("g"); Text("h") } }`: the two texts hang under + // a tuple under an optional, and layout places them as layers of the ZStack. + func tree(under container: String) -> Projection { + projection([ + node( + "root", children: ["root/index(0)", "root/index(1)"], type: container, + frame: (0, 0, 4, 2)), + node("root/index(0)", parent: "root", frame: (0, 0, 4, 1)), + node( + "root/index(1)", parent: "root", children: ["root/index(1)/branch(true)"], + type: "Optional", frame: (0, 0, 1, 1)), + node( + "root/index(1)/branch(true)", parent: "root/index(1)", + children: [ + "root/index(1)/branch(true)/index(0)", "root/index(1)/branch(true)/index(1)", + ], + type: "TupleView", frame: (0, 0, 1, 1)), + node( + "root/index(1)/branch(true)/index(0)", parent: "root/index(1)/branch(true)", + frame: (0, 0, 1, 1)), + node( + "root/index(1)/branch(true)/index(1)", parent: "root/index(1)/branch(true)", + frame: (0, 0, 1, 1)), + ]) + } + + #expect(run(.siblingsDisjoint, tree(under: "ZStack")).isEmpty) + // Under a stack the same two texts on one frame are the defect, and so is the text + // that the optional puts over `base`. + #expect( + run(.siblingsDisjoint, tree(under: "VStack")).map(\.node) == [ + "root/index(1)", + "root/index(1)/branch(true)/index(1)", + ]) + } + + @Test + func + `a conditional branch and nested lists are looked through to the nearest container`() + { + func tree(under container: String, layout: String? = nil) -> Projection { + projection([ + node( + "root", children: ["root/index(0)"], type: container, layout: layout, + frame: (0, 0, 4, 2)), + node( + "root/index(0)", parent: "root", children: ["root/index(0)/branch(false)"], + type: "ConditionalView", frame: (0, 0, 4, 1)), + node( + "root/index(0)/branch(false)", parent: "root/index(0)", + children: [ + "root/index(0)/branch(false)/id(a)", "root/index(0)/branch(false)/id(b)", + ], + type: "TupleView", frame: (0, 0, 4, 1)), + node( + "root/index(0)/branch(false)/id(a)", parent: "root/index(0)/branch(false)", + frame: (0, 0, 4, 1)), + node( + "root/index(0)/branch(false)/id(b)", parent: "root/index(0)/branch(false)", + frame: (0, 0, 4, 1)), + ]) + } + + #expect(run(.siblingsDisjoint, tree(under: "ZStack")).isEmpty) + #expect(run(.siblingsDisjoint, tree(under: "HStack")).count == 1) + #expect( + run(.siblingsDisjoint, tree(under: "_LayoutContainer", layout: "Stagger")).count + == 1) + } + + @Test + func `a list that is not under any container is held to disjoint items`() { + // The root itself is a tuple: with nothing above it, its elements are its siblings. + let found = run( + .siblingsDisjoint, + projection([ + node( + "root", children: ["root/index(0)", "root/index(1)"], type: "TupleView", + frame: (0, 0, 4, 1)), + node("root/index(0)", parent: "root", frame: (0, 0, 4, 1)), + node("root/index(1)", parent: "root", frame: (0, 0, 4, 1)), + ])) + + #expect(found.map(\.node) == ["root/index(1)"]) + } + @Test func `a custom layout is held to disjoint children under its own name`() { let found = run( diff --git a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift index 3c7bb6d..f6d6c38 100644 --- a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift @@ -411,6 +411,90 @@ struct HarnessCLIScenarioTests { #expect(greedy.stderr.contains("mobileScreen")) } + @Test + func `run --pretty shows a bare check's warnings under the scenario's line`() + async throws + { + let scratch = try ScratchDirectory() + try scratch.write("w.scn", "app overflow\ncheck\n") + + let output = await projectionRun("run", scratch.path, "--pretty") + + #expect(output.exitCode == 1, "the error of the same check fails the run") + let lines = output.stdout.split(separator: "\n").map(String.init) + let warning = try #require(lines.firstIndex { $0.hasPrefix(" line 2: warning ") }) + let failure = try #require(lines.firstIndex(of: " line 2: check")) + #expect(lines[warning].contains("frame-inside-clip root")) + #expect(warning < failure, "the warnings come before the failures") + #expect(lines[failure + 1].hasPrefix(" measured-within-proposal at root")) + } + + @Test + func `a scenario that passes with warnings prints them under its PASS line`() { + let report = ScenarioReport( + scenario: "flex.scn", failures: [], + observations: [ + ScenarioObservation( + line: 7, verb: "check", name: nil, + lines: [ + "ran no-dirty-after-pass, frame-inside-clip", + "warning frame-inside-clip root/a: shows 0,0,9x1", + ]), + ScenarioObservation( + line: 9, verb: "check", name: nil, lines: ["ran no-dirty-after-pass"]), + ScenarioObservation( + line: 3, verb: "shot", name: "a", lines: ["warning: not a check"]), + ]) + + #expect( + report.description + == "PASS flex.scn\n line 7: warning frame-inside-clip root/a: shows 0,0,9x1") + #expect(report.json.contains("warning frame-inside-clip root/a")) + } + + @Test + func `an empty path, name, or flag value is a usage error and nothing runs`() + async throws + { + let scratch = try ScratchDirectory() + try scratch.write("a.scn", passing) + let out = scratch.appending("traces") + + let trace = await run("run", scratch.path, "--trace", "", "--pretty") + let path = await run("run", "", "--pretty") + let inside = await run("run", scratch.path, "", "--pretty") + let shot = await run("shot", "", "--pretty") + let tree = await run("tree", "", "--pretty") + let check = await run("check", "", "--pretty") + let file = await run("replay", "", "--app", "stack", "--pretty") + let app = await run("replay", "t.jsonl", "--app", "", "--pretty") + let json = await run("run", scratch.path, "--trace", "") + + for output in [trace, path, inside, shot, tree, check, file, app, json] { + #expect(output.exitCode == 2, "\(output.stderr)\(output.stdout)") + } + #expect(trace.stderr == "error: `--trace` needs a value that is not empty\n") + #expect(trace.stdout.isEmpty, "no scenario ran") + #expect( + path.stderr == "error: an empty argument is not a path or an application name\n") + #expect(app.stderr == "error: `--app` needs a value that is not empty\n") + #expect( + try object(json)["message"] as? String + == "`--trace` needs a value that is not empty") + #expect(!FileManager.default.fileExists(atPath: out)) + } + + @Test + func `the help overview spells replay's required flag`() async { + let overview = await run("help") + + #expect(overview.exitCode == 0) + #expect( + overview.stdout.contains( + " replay FILE --app NAME rerun a recorded trace against an application\n")) + #expect(overview.stdout.contains(" check NAME [SIZE] check one application")) + } + // MARK: trace and replay @Test diff --git a/Tests/TesseraHarnessTests/ProjectionFixtures.swift b/Tests/TesseraHarnessTests/ProjectionFixtures.swift index bfd4a0b..5a68823 100644 --- a/Tests/TesseraHarnessTests/ProjectionFixtures.swift +++ b/Tests/TesseraHarnessTests/ProjectionFixtures.swift @@ -143,6 +143,44 @@ final class GrowingApp: Application { } } +/// Layers that a list and an `if` build under a `ZStack`: layout looks through both, so the +/// items share one frame by contract. +final class ListLayersApp: Application { + private let shown = true + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ZStack { + ForEach(["abcd", "ef"], id: \.self) { layer in + Text(layer) + } + } + ZStack { + Text("base") + if shown { + Text("g") + Text("h") + } + } + } + } + + func apply(_ event: InputEvent) {} +} + +/// The same list under a layout that is not a `ZStack`: its items on one frame are the defect. +final class ListOverlapApp: Application { + var body: some View { + Overlap { + ForEach(["a", "b"], id: \.self) { name in + Text(name) + } + } + } + + func apply(_ event: InputEvent) {} +} + enum ProjectionFixtures { /// The apps the projection, check, and trace tests run, each at one registered name. @MainActor @@ -155,6 +193,8 @@ enum ProjectionFixtures { registry.register("wide", viewports: [.desktop]) { WideApp() } registry.register("steady", viewports: [.desktop]) { SteadyApp() } registry.register("growing", viewports: [.desktop]) { GrowingApp() } + registry.register("list-layers", viewports: [.desktop]) { ListLayersApp() } + registry.register("list-overlap", viewports: [.desktop]) { ListOverlapApp() } return registry } } diff --git a/Tests/TesseraHarnessTests/ReplayTests.swift b/Tests/TesseraHarnessTests/ReplayTests.swift index 5a1f404..aee26a6 100644 --- a/Tests/TesseraHarnessTests/ReplayTests.swift +++ b/Tests/TesseraHarnessTests/ReplayTests.swift @@ -150,7 +150,7 @@ struct ReplayTests { { var records = try await record("steady", [key("+"), key("+")]) records[1] = TraceRecord( - seq: 1, event: TraceEvent(.focusGained), drew: false, reasons: [], + seq: 1, event: TraceEvent(.unknown([0x1b, 0x5b])), drew: false, reasons: [], nodes: .init(added: [], changed: [], removed: []), rows: [], cost: StepCost(bodies: 0, bytes: 0, measures: 0, placements: 0, renders: 0)) @@ -160,10 +160,28 @@ struct ReplayTests { let failure = try #require(report.failures.first) #expect(failure.verb == "usage") #expect(failure.line == 2) - #expect(failure.message.contains("focusGained")) + #expect(failure.message.contains("unknown")) #expect(report.observations.isEmpty, "nothing ran") } + @Test + func `a trace with the terminal's focus changes replays like any other`() async throws { + // A live session records a focus change as it receives it, and that must not make the + // whole trace unreplayable. + let records = try await record( + "growing", [.focusGained, key("+"), .focusLost, key("+"), .focusGained]) + + #expect( + records.map(\.event.type) == [ + "resize", "focusGained", "key", "focusLost", "key", "focusGained", + ]) + let report = await replay(records, app: "growing") + + #expect(report.passed, "\(report)") + #expect(report.trace.map(\.event) == records.map(\.event)) + #expect(report.observations.first?.lines == ["a", "b", "c"]) + } + @Test func `a trace must start with step 0, the resize that gives the starting size`() async throws diff --git a/Tests/TesseraHarnessTests/ScenarioStatementsTests.swift b/Tests/TesseraHarnessTests/ScenarioStatementsTests.swift index 1b52157..b0e0246 100644 --- a/Tests/TesseraHarnessTests/ScenarioStatementsTests.swift +++ b/Tests/TesseraHarnessTests/ScenarioStatementsTests.swift @@ -11,7 +11,8 @@ private func parse(_ source: String) throws -> Scenario { extension ProjectionFixtures { static let names = [ - "overlap", "overflow", "layers", "styled", "wide", "steady", "growing", + "overlap", "overflow", "layers", "styled", "wide", "steady", "growing", "list-layers", + "list-overlap", ] } @@ -108,6 +109,20 @@ struct NewStatementParserTests { message("expect node colour=red frame 0,0,1x1") == "unknown locator key `colour`; accepted: type, layout, id, text, nth") } + + @Test + func + `a locator key with an empty value is a parse error and not a query for everything`() + { + for key in ["type", "layout", "id", "text"] { + #expect( + message("expect node \(key)= frame 0,0,1x1") + == "locator key `\(key)` needs a value that is not empty") + } + #expect( + message(#"expect node text="" frame 0,0,1x1"#) + == "locator key `text` needs a value that is not empty") + } } // MARK: Playing @@ -137,6 +152,38 @@ struct NewStatementPlayerTests { ]) } + @Test + func `layers a ForEach and an if build under a ZStack pass siblings-disjoint`() + async throws + { + // The items really do share a frame, which is what makes this the case worth checking. + let report = try await play( + """ + app list-layers + expect node id=abcd frame 0,0,4x1 + expect node id=ef frame 0,0,2x1 + expect node type=Text text="h" frame 0,1,1x1 + check siblings-disjoint + check + """) + + #expect(report.passed, "\(report)") + #expect(report.observations.count == 2) + } + + @Test + func `a list on a layout that is not a ZStack still fails siblings-disjoint`() + async throws + { + let report = try await play("app list-overlap\ncheck siblings-disjoint") + + let failure = try #require(report.failures.first) + #expect(report.failures.count == 1) + #expect(failure.rule == "siblings-disjoint") + #expect(failure.node?.hasSuffix("id(b)") == true) + #expect(failure.message.contains("under ForEach")) + } + @Test func `a violated rule fails the statement with its rule and node`() async throws { let report = try await play("app overlap\ncheck siblings-disjoint") diff --git a/Tests/TesseraHarnessTests/ScreenGridTests.swift b/Tests/TesseraHarnessTests/ScreenGridTests.swift index 63a5369..02e3874 100644 --- a/Tests/TesseraHarnessTests/ScreenGridTests.swift +++ b/Tests/TesseraHarnessTests/ScreenGridTests.swift @@ -59,11 +59,13 @@ struct ScreenGridTests { // MARK: Styled grid @Test - func `a screen with no styled cell has an empty style section and a default legend`() { + func `a screen with no styled cell has an empty style section and a default legend`() + throws + { let screen = grid([plain("Hi", width: 4), plain("", width: 4)]) #expect( - screen.styledGridText + try screen.styledGridText() == """ ── chars ── Hi @@ -77,6 +79,7 @@ struct ScreenGridTests { @Test func `style letters are handed out in reading order and rows lose trailing default cells`() + throws { let bold = cell("A", bold: true) let accent = cell("B", foreground: .rgb(0, 128, 255), bold: true) @@ -87,7 +90,7 @@ struct ScreenGridTests { ]) #expect( - screen.styledGridText + try screen.styledGridText() == """ ── chars ── xAAy @@ -104,12 +107,12 @@ struct ScreenGridTests { } @Test - func `a continuation cell is styled like the wide grapheme it continues`() { + func `a continuation cell is styled like the wide grapheme it continues`() throws { let wide = cell("東", background: .indexed(4), italic: true) let screen = grid([[wide, cell(" "), cell("a")]]) #expect( - screen.styledGridText + try screen.styledGridText() == """ ── chars ── 東a @@ -150,4 +153,122 @@ struct ScreenGridTests { ScreenGrid.StyleRun(row: 1, column: 0, columns: 1, style: "bold"), ]) } + + // MARK: Grid rows + + /// The chars rows and the style rows of a grid's text. + private func sections(of text: String) -> ( + chars: [String], style: [String], legend: [String] + ) { + var chars: [String] = [] + var style: [String] = [] + var legend: [String] = [] + var section = "" + for line in text.split(separator: "\n", omittingEmptySubsequences: false).dropLast() { + if line.hasPrefix("── ") { + section = String(line) + continue + } + switch section { + case "── chars ──": chars.append(String(line)) + case "── style ──": style.append(String(line)) + default: legend.append(String(line)) + } + } + return (chars, style, legend) + } + + @Test + func `a styled blank row below the text keeps its place in the chars section`() throws { + let fill = cell(" ", background: .indexed(4)) + let screen = grid([ + plain("Hi", width: 4), [fill, fill, fill, fill], [fill, fill, fill, fill], + ]) + + #expect(screen.textRows == ["Hi", "", ""]) + #expect( + try screen.styledGridText() + == """ + ── chars ── + Hi + + + ── style ── + + AAAA + AAAA + ── legend ── + . default + A bg=indexed(4) + + """) + } + + @Test + func `the text golden still ends at the last row with text`() throws { + let fill = cell(" ", background: .indexed(4)) + let screen = grid([plain("Hi", width: 4), [fill, fill, fill, fill]]) + + #expect(GoldenStore.text(of: screen.textRows) == "Hi\n") + #expect(try sections(of: screen.styledGridText()).chars == ["Hi", ""]) + } + + @Test + func `a grid never has more style rows than chars rows`() throws { + let bold = cell("a", bold: true) + let fill = cell(" ", background: .indexed(1)) + let screens: [ScreenGrid] = [ + grid([]), + grid([plain("", width: 3)]), + grid([plain("text", width: 4), plain("", width: 4)]), + grid([[bold], plain("", width: 1), plain("", width: 1)]), + grid([plain("", width: 2), [fill, fill], plain("x", width: 2), [fill, fill]]), + grid([[fill, fill], [fill, fill], plain("", width: 2), plain("", width: 2)]), + ] + for screen in screens { + let found = try sections(of: screen.styledGridText()) + #expect(found.style.count <= found.chars.count, "\(screen.textRows)") + #expect(found.style.last?.isEmpty != true, "\(screen.textRows)") + #expect(found.chars.last?.isEmpty != true || found.style.count == found.chars.count) + } + } + + @Test + func `a grid names up to 62 styles, one letter each`() throws { + let cells = (0..<62).map { cell("x", foreground: .rgb(UInt8($0), 0, 0)) } + let found = try sections(of: grid([cells]).styledGridText()) + + #expect(found.legend.count == 63) + #expect(Set(found.legend.compactMap(\.first)).count == 63) + #expect(found.legend.last == "9 fg=#3d0000") + #expect(found.style == [String(found.legend.dropFirst().compactMap(\.first))]) + } + + @Test + func `a screen with 63 styles is refused, not written with a repeated letter`() { + let cells = (0..<63).map { cell("x", foreground: .rgb(UInt8($0), 0, 0)) } + let screen = grid([cells]) + + #expect(throws: ScreenGrid.GridError(styles: 63)) { + try screen.styledGridText() + } + #expect(ScreenGrid.GridError(styles: 63).description.contains("63 distinct styles")) + } + + @Test + func `a shot of a screen with too many styles fails as a harness error in both modes`() + { + let cells = (0..<63).map { cell("x", foreground: .rgb(UInt8($0), 0, 0)) } + let screen = grid([cells]) + + for record in [false, true] { + let store = GoldenStore(scenarioPath: "/nonexistent/many.scn", record: record) + guard case .failed(let message) = store.check(shot: "wide", screen: screen) else { + Issue.record("record \(record): the shot did not fail as a harness error") + continue + } + #expect(message.contains("shot `wide`")) + #expect(message.contains("63 distinct styles")) + } + } } diff --git a/Tests/TesseraHarnessTests/TraceTests.swift b/Tests/TesseraHarnessTests/TraceTests.swift index 093ca7e..5f9a13f 100644 --- a/Tests/TesseraHarnessTests/TraceTests.swift +++ b/Tests/TesseraHarnessTests/TraceTests.swift @@ -137,27 +137,43 @@ struct TraceTests { } @Test - func `an event the grammar cannot say is recorded as other and cannot be replayed`() { - for event in [ - InputEvent.focusGained, .focusLost, .unknown([0x1b, 0x5b]), - .primaryDeviceAttributes([1]), - ] { + func `an event neither the grammar nor replay can say is recorded as other`() { + for event in [InputEvent.unknown([0x1b, 0x5b]), .primaryDeviceAttributes([1])] { let recorded = TraceEvent(event) #expect(recorded.type == "other") #expect(recorded.inputEvent == nil) + #expect(recorded.summary.hasSuffix("(not replayable)")) } let capsLock = TraceEvent(.key(Key(code: .character("a"), modifiers: .capsLock))) #expect(capsLock.type == "other") #expect(capsLock.detail?.hasPrefix("key") == true) } + @Test + func `a focus change of the terminal is recorded by name and delivers again`() throws { + let gained = TraceEvent(.focusGained) + let lost = TraceEvent(.focusLost) + + #expect(gained.type == "focusGained") + #expect(lost.type == "focusLost") + #expect(gained.inputEvent == .focusGained) + #expect(lost.inputEvent == .focusLost) + #expect(gained.summary == "focus gained") + #expect(lost.summary == "focus lost") + // Nothing else rides along, so a record is just the type. + #expect(JSONText.string(for: gained) == #"{"type":"focusGained"}"#) + let decoded = try JSONDecoder().decode( + TraceEvent.self, from: Data(JSONText.string(for: lost).utf8)) + #expect(decoded == lost) + } + @Test func `a recorded event delivers again as the event it came from`() { let events: [InputEvent] = [ .key(Key(code: .left)), .key(Key(code: .character("c"), modifiers: [.control, .alt])), .key(Key(code: .function(5))), .key(Key(code: .character("a"), kind: .release)), - .paste("x y"), .resize(TerminalSize(columns: 7, rows: 3)), + .paste("x y"), .resize(TerminalSize(columns: 7, rows: 3)), .focusGained, .focusLost, ] for event in events { @@ -218,6 +234,28 @@ struct TraceTests { #expect(later?.message.contains("version 2") == true) } + @Test + func `a record of another version is named by its version even when its keys are gone`() + throws + { + // A later schema may rename or drop any key but `version`, so the version is read first. + let renamed = #expect(throws: TraceFile.ReadError.self) { + try TraceFile.records(in: #"{"version":2,"steps":[],"verdict":"new"}"# + "\n") + } + let older = #expect(throws: TraceFile.ReadError.self) { + try TraceFile.records(in: #"{"version":0}"# + "\n") + } + let noVersion = #expect(throws: TraceFile.ReadError.self) { + try TraceFile.records(in: #"{"seq":0}"# + "\n") + } + + #expect(renamed?.line == 1) + #expect(renamed?.message == "the record has version 2; this harness reads version 1") + #expect(older?.message == "the record has version 0; this harness reads version 1") + #expect(noVersion?.message.contains("not a trace record") == true) + #expect(noVersion?.message.contains("version") == true) + } + @Test func `a scenario's trace file mirrors its path under the directory and stays inside it`() diff --git a/docs/architecture/developer-tooling.md b/docs/architecture/developer-tooling.md index a68de5f..5afa6da 100644 --- a/docs/architecture/developer-tooling.md +++ b/docs/architecture/developer-tooling.md @@ -126,7 +126,7 @@ The `term` header names the terminal the app runs on, not the machine running th A selector (`SEL`) is a set of `key=value` pairs that addresses nodes by meaning and re-resolves on every statement: -- `type=Text` (short type name), `layout=_FrameLayout` (the algorithm of a `_LayoutContainer`: `frame`, `padding`, or an app's own `Layout`), `id=X` (the id the app gave a node, a `.id(X)` or a `ForEach` key, or else a full identity path), `text="Submit"` (screen text attributed to the topmost leaf, so obscured text does not match), `nth=2`. These are built. +- `type=Text` (short type name), `layout=_FrameLayout` (the algorithm of a `_LayoutContainer`: `frame`, `padding`, or an app's own `Layout`), `id=X` (the id the app gave a node, a `.id(X)` or a `ForEach` key, or else a full identity path), `text="Submit"` (a substring of the screen text attributed to the topmost leaf, so obscured text does not match; an empty value is a parse error), `nth=2`. These are built. - `focused`, `focusable`. Designed. - `role=button`, `label="Submit"`. Designed: library controls set role and label through an internal hook; there is no public view API for it. A node without them falls back to a role derived from its type and a label from its screen text. The fields are plain values, so a later accessibility or Tern Surface Protocol export can read the same description. @@ -147,7 +147,7 @@ Built today: `Projection` in `TesseraHarness` is built from `GraphDiagnostics` a | `no-dirty-after-pass` | a node still dirty after a completed pass | | `frame-inside-clip` (warning) | content placed outside its clip | | `measured-within-proposal` | a view claiming more than it was offered | -| `siblings-disjoint` | stack children that show a cell in common (the `ForEach` defect: every item on one frame); `ZStack` and overlays are exempt | +| `siblings-disjoint` | stack children that show a cell in common (the `ForEach` defect: every item on one frame); `ZStack` and overlays are exempt, also through the `ForEach`, `if`, or group of views that builds their layers | | `requirements-aggregate` | terminal requirements that do not roll up to the root | | `exactly-one-focus`, `focus-resolves` | zero or several focused nodes; focus on a node that no longer exists | | `focused-visible`, `focus-indicated` | focus on a node that is clipped away; focus with no visible difference, including under `colors=none` | @@ -162,7 +162,7 @@ Single ownership of each `NodeState` row cannot be seen in the projection, so it `shot NAME [SEL]` records the screen, or one node's frame region, as two text files beside the scenario: `NAME.txt` (the trimmed text) and `NAME.grid.txt` (the styled cell grid). They are the only committed artefacts. Drift from a committed golden fails the statement; `run --record` rewrites goldens and prints what changed. Never use `--record` to make a failing run pass. -Built today: `shot NAME` compares two files with the screen, each in a directory named like the scenario file without its extension, beside it (`counter-basics.scn` keeps its goldens in `counter-basics/`), so shot names only need to be unique within a scenario. `NAME.txt` is the trimmed screen text, every row without its trailing spaces and trailing empty rows dropped, with a wide grapheme as one character. `NAME.grid.txt` is the catalog's styled grid of the same screen: `── chars ──` (the same rows), `── style ──` (one letter under each cell, `.` for the default style, trailing default cells left off, so an unstyled screen has no style rows), and `── legend ──` (each letter named in reading order, a colour as `#rrggbb` or `indexed(N)`; a colour that a semantic role owns is spelled as the role once `theme=probe` exists). A missing golden or a drifted one fails the `shot` statement with the first differing row of the first golden that differs, the text before the grid, and the screen, and `tessera run --record` writes the files and lists what changed; a golden checked out with CRLF fails with a message that says so, and `.gitattributes` keeps this repository's goldens LF. The SVG and the PNG are designed and not built, and `shot` takes no selector yet. +Built today: `shot NAME` compares two files with the screen, each in a directory named like the scenario file without its extension, beside it (`counter-basics.scn` keeps its goldens in `counter-basics/`), so shot names only need to be unique within a scenario. `NAME.txt` is the trimmed screen text, every row without its trailing spaces and trailing empty rows dropped, with a wide grapheme as one character. `NAME.grid.txt` is the catalog's styled grid of the same screen: `── chars ──` (the rows of `NAME.txt`, and an empty row below them for each blank row that has a style, down to the last styled row, so a background below the text is kept and the grid never has more style rows than chars rows), `── style ──` (one letter under each cell, `.` for the default style, trailing default cells left off, so an unstyled screen has no style rows), and `── legend ──` (each letter named in reading order, a colour as `#rrggbb` or `indexed(N)`, the spelling this page fixes because the catalog's dialect knows only `fg=ROLE`; a colour that a semantic role owns is spelled as the role once `theme=probe` exists). A grid has 62 letters (`A-Z`, `a-z`, `0-9`); a screen with more distinct styles fails its `shot` as a harness error, since a letter that names two styles would make the golden ambiguous. A missing golden or a drifted one fails the `shot` statement with the first differing row of the first golden that differs, the text before the grid, and the screen, and `tessera run --record` writes the files and lists what changed; a golden checked out with CRLF fails with a message that says so, and `.gitattributes` keeps this repository's goldens LF. The SVG and the PNG are designed and not built, and `shot` takes no selector yet. Shot names are unique within a scenario ignoring case, since goldens are files and the macOS and Windows file systems fold case: `shot a` and `shot A` is a parse error. A golden that holds a carriage return fails as a CRLF golden before any row is compared, and a missing or CRLF golden reports no expected or actual row, because there is nothing to compare. @@ -172,7 +172,7 @@ Each shot also writes an SVG to `.build/tessera/` (one text element per row, one The driver writes one JSONL record per step: sequence number, event, the pass's invalidation reasons, the nodes and rows that changed, cost (bodies evaluated, measures, placements, rendered nodes, bytes written), `dispatch{disposition, handledBy, path}`, and `focus{from, to, cause}`. "Why did this redraw?", "which node swallowed this key?", and "why did focus move?" are all read from the trace. Cost is deterministic, so `expect cost` can assert it; wall-clock durations appear only under `run --timing` and are never golden. `run --repeat N` reruns a scenario and reports any divergence. A live session records its events to `TESSERA_TRACE=` for `tessera replay`. -Built today: every step of every host, headless and live, is one record (`seq`, `event`, `drew`, `reasons`, `nodes`, `rows`, `cost`, `version`), written by the one `Driver`. `seq` 0 is the first frame, whose event is the resize that tells the app its starting size. `event` is spelled as the scenario statement spells it (`{"type": "key", "key": "Shift+Tab"}`, a `paste`, a `resize` as `WxH`) and is `other` for an event the grammar cannot say. `drew` says whether the step drew a frame, because a step that leaves the graph clean draws none and costs nothing. `reasons` is the library's invalidation reasons for the pass, in their fixed order, so a resize shows `layoutViewportChanged` and `renderViewportChanged` and a key that changed a view shows `layoutViewChanged` and `renderViewChanged`; a step in which the library redraws although nothing visible changed shows that too. `nodes` lists the identity paths added, changed (type, geometry, or requirements), and removed, and `rows` the screen rows whose text changed. `cost` is the counters `expect cost` bounds: `bodies`, `bytes` (what `TerminalSession.bytesDrawn` counted for the frame), `measures`, `placements`, and `renders`. `dispatch` and `focus` arrive with the input routing and focus slices that produce them. `run --trace DIR` writes a file per scenario, `launch` under `TESSERA_TRACE` writes the live session's, and `replay` runs either again, comparing everything but the bytes. The trace is not promised to stay as it is; each record carries `version`. +Built today: every step of every host, headless and live, is one record (`seq`, `event`, `drew`, `reasons`, `nodes`, `rows`, `cost`, `version`), written by the one `Driver`. `seq` 0 is the first frame, whose event is the resize that tells the app its starting size. `event` is spelled as the scenario statement spells it (`{"type": "key", "key": "Shift+Tab"}`, a `paste`, a `resize` as `WxH`); the terminal's focus changes are `focusGained` and `focusLost`, which a live session receives and no statement says yet but which replay delivers; and any other event (a mouse event, a terminal reply) is `other`, which `replay` refuses by name. `drew` says whether the step drew a frame, because a step that leaves the graph clean draws none and costs nothing. `reasons` is the library's invalidation reasons for the pass, in their fixed order, so a resize shows `layoutViewportChanged` and `renderViewportChanged` and a key that changed a view shows `layoutViewChanged` and `renderViewChanged`; a step in which the library redraws although nothing visible changed shows that too. `nodes` lists the identity paths added, changed (type, geometry, or requirements), and removed, and `rows` the screen rows whose text changed. `cost` is the counters `expect cost` bounds: `bodies`, `bytes` (what `TerminalSession.bytesDrawn` counted for the frame), `measures`, `placements`, and `renders`. `dispatch` and `focus` arrive with the input routing and focus slices that produce them. `run --trace DIR` writes a file per scenario, `launch` under `TESSERA_TRACE` writes the live session's, and `replay` runs either again, comparing everything but the bytes. The trace is not promised to stay as it is; each record carries `version`. ### `tessera` diff --git a/docs/design/primitives/text.md b/docs/design/primitives/text.md index 33c0584..5ea9e63 100644 --- a/docs/design/primitives/text.md +++ b/docs/design/primitives/text.md @@ -412,4 +412,4 @@ Each is deferred to the plan step that lands it: ## Inspiration -The Text demo renders ordinary, wide, combining, multiline, wrapped, budget-truncated, and mode-truncated Text fixtures before it introduces focusable controls. Text has no demo-only behavior; the demo proves the public leaf through a real `ViewGraph` and `Frame`. +The Text demo renders ordinary, wide, emoji, combining, half-width, and multiline Text fixtures at both viewports before it introduces focusable controls. Wrapped, budget-truncated, and mode-truncated fixtures join it when wrapping and truncation land with the styling slice (Slice 8 of [plan 036](../../plans/phase-4-view-layer-and-developer-tooling.md)); `Text` neither wraps nor truncates before then. Text has no demo-only behavior; the demo proves the public leaf through a real `ViewGraph` and `Frame`. From 6ea1bd5ead22ca3b24ea43e4127303297fb19e82 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 06:49:16 -0400 Subject: [PATCH 25/55] docs(plans): record the critic review fixes for Slice 6 --- docs/plans/phase-4-view-layer-and-developer-tooling.md | 6 +++++- 1 file changed, 5 insertions(+), 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 65e19ce..8d7a56f 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -19,7 +19,7 @@ This plan merges plan 032 (Phases 8-17, its open implementation phases; Phases 1 - [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 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. - [x] 5 Scenario language, runner, the `tessera` CLI, and the CI corpus: `3faa2f4` and `ef4592b` on `phase-4/05-scenarios` (PR #66, stacked on #65); full `mise run core:test` runs 838 tests green on Xcode 26.6 and on Xcode 27.1 (750 on the base; 88 added across the parser, player, runner, golden, report, answerer, and command tests in `TesseraHarnessTests`), `swift test --package-path Examples` runs 25 tests on both toolchains (23 on the base; `ScenarioCorpusTests` added), and `mise run quality:format`, `quality:lint`, and `docs:lint` are green; neither `Package.resolved` changed. Two runs of `tessera run Examples/Scenarios` give the same bytes (`shasum` `55fa9588…` twice, 6,996 bytes of JSON); a parse error under `--json` is a report with `"verb":"parse"` and exit 2; a drifted golden fails without `--record` (exit 1, the first differing row named) and `--record` rewrites it (`(updated)`); and the local CI probe, with `counter-basics.scn`'s `expect text 2` changed to a wrong count, exits 1 with its failure at line 19 and exits 0 once reverted (reports below). CI on `ef4592b` (run 40074331235) and on the plan record `5b4dabf` (run 40075060791) passed all 14 jobs, and every `test` leg ran `tessera run --pretty Examples/Scenarios` and printed `5 scenarios, 5 passed, 0 failed`, `test (windows-latest)` included, with its goldens checked out by Git for Windows' defaults and LF by `.gitattributes`; the first run (`3faa2f4`, run 40073722211) failed only that leg, in the README table test, because a Windows checkout of `README.md` has CRLF endings and a `"\r\n"` is one `Character` (fixed in `ef4592b`). The seven findings of the critic review of PR #66 (the app finishing, case-folded shot names, a CRLF golden that had also drifted, `run --repeat PATH`, the unnamed JSON fields, the heredoc closer, and `check`'s app name) are addressed on top of it, and the PR carries the head's run. -- [x] 6 Projection, locators, invariants, cost, trace, and shots, with the first demos: `6781c57` (the library: a resize's viewport reasons survive `update()`, and `TerminalSession.bytesDrawn`), `10f8484` (the projection, rules, `expect cost`, trace, grid goldens, and the six demos with their 12 scenarios), and `b2c3161` (an `expect cost` bound in the `ForEach` scenario) on `phase-4/06-projection` (PR #67, stacked on #66, merged with `origin/phase-4/05-scenarios` at `1cd10ea`); full `mise run core:test` runs 934 tests green on Xcode 26.6 and on Xcode 27.1 (838 on the base; 93 added in `TesseraHarnessTests` across the type-name, grid, projection, rule, statement, trace, replay, and command tests, and two `bytesDrawn` tests and one resize-reason test in the library), `swift test --package-path Examples` runs 36 tests on both toolchains (25 on the base; `PrimitiveDemoTests` added and the corpus floor raised to 16), and `mise run quality:format`, `quality:lint`, and `docs:lint` are green; neither `Package.resolved` changed, and `git diff --name-only origin/phase-4/05-scenarios...HEAD` lists none. `mise run dev:run Examples/Scenarios` runs 16 scenarios, 16 passed, and two runs give the same bytes (`shasum` `15ca662c…` twice, 30,265 bytes of JSON; `--repeat 3` agrees); `siblings-disjoint` passes on the `foreach` demo after items are added, removed, and reversed (`check` after each step of `foreach-desktop.scn`). `tessera check foreach` exits 0 and names its five rules; `tessera check workspace mobileScreen` exits 1 with `error measured-within-proposal root/index(2)/id(catalog)/index(0)/index(0): measured 7 columns where 0 were offered` (Discoveries); tightening `foreach-desktop.scn`'s `expect cost bytes<=1000` to `<=100` fails with `step 1 (key +) took 430 bytes, above the bound 100`. `tessera run --trace` writes five records for `foreach-desktop.scn`, and every redraw names its reasons (`layoutEnvironmentChanged`, `layoutViewChanged`, `renderEnvironmentChanged`, `renderRequested`, `renderViewChanged`, `updateRequested`), with 2,577 bytes for the first frame and 430, 81, 473, and 457 for the four steps after it. CI on `b2c3161` (run 40083068340) passed all 14 jobs, and every `test` leg printed `16 scenarios, 16 passed, 0 failed`, `test (windows-latest)` included, with the new grid goldens checked out as LF. +- [x] 6 Projection, locators, invariants, cost, trace, and shots, with the first demos: `6781c57` (the library: a resize's viewport reasons survive `update()`, and `TerminalSession.bytesDrawn`), `10f8484` (the projection, rules, `expect cost`, trace, grid goldens, and the six demos with their 12 scenarios), `b2c3161` (an `expect cost` bound in the `ForEach` scenario), and `a7260a7` (the critic review's fixes: list-looking `siblings-disjoint`, the grid row rule, 62 styles, `run --pretty` warnings, empty arguments, the trace version and focus events, and the Flex, Stacks, and ZStack demo gaps) on `phase-4/06-projection` (PR #67, stacked on #66, merged with `origin/phase-4/05-scenarios` at `1cd10ea`); full `mise run core:test` runs 954 tests green on Xcode 26.6 and on Xcode 27.1 (838 on the base; 113 added in `TesseraHarnessTests` across the type-name, grid, projection, rule, statement, trace, replay, and command tests, and two `bytesDrawn` tests and one resize-reason test in the library), `swift test --package-path Examples` runs 39 tests on both toolchains (25 on the base; `PrimitiveDemoTests` added and the corpus floor raised to 16), and `mise run quality:format`, `quality:lint`, and `docs:lint` are green (the pre-commit hook's lint of `Examples/Tests` too); neither `Package.resolved` changed, and `git diff --name-only origin/phase-4/05-scenarios...HEAD` lists none. `mise run dev:run Examples/Scenarios` runs 16 scenarios, 16 passed, and two runs give the same bytes (`shasum` `968b46d6…` twice, 34,563 bytes of JSON; `--repeat 3` agrees); `siblings-disjoint` passes on the `foreach` demo after items are added, removed, and reversed (`check` after each step of `foreach-desktop.scn`), and on `zstack` with layers from a `ForEach` and an `if` (it fails there with the list-looking removed). `tessera check foreach` exits 0 and names its five rules; `tessera check workspace mobileScreen` exits 1 with `error measured-within-proposal root/index(2)/id(catalog)/index(0)/index(0): measured 7 columns where 0 were offered` (Discoveries); tightening `foreach-desktop.scn`'s `expect cost bytes<=1000` to `<=100` fails with `step 1 (key +) took 430 bytes, above the bound 100`. `tessera run --trace` writes five records for `foreach-desktop.scn`, and every redraw names its reasons (`layoutEnvironmentChanged`, `layoutViewChanged`, `renderEnvironmentChanged`, `renderRequested`, `renderViewChanged`, `updateRequested`), with 2,577 bytes for the first frame and 430, 81, 473, and 457 for the four steps after it. CI on `b2c3161` (run 40083068340) and on the plan record `5b8b4c1` (run 40083937741) passed all 14 jobs, and every `test` leg printed `16 scenarios, 16 passed, 0 failed`, `test (windows-latest)` included, with the new grid goldens checked out as LF; the PR carries the head's run after the critic fixes. - [ ] 7 Flow layout and `ViewThatFits` - [ ] 8 Styling and decoration, with pictures, roles, and catalog review - [ ] 9 Focus, key routing, and KeyLegend, with focus tooling @@ -290,6 +290,7 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-24 `expect cost NAME<=N…` bounds the last step the scenario took, and only with `<=` (owner to weigh). The counters are `bodies`, `bytes`, `measures`, `placements`, and `renders`; the step is the last event delivered, so the statement can follow a `shot` or an `expect`; a failure prints the whole record (`bodies=0 bytes=430 measures=143 placements=34 renders=18`). `bytes` is the size of the step's frame, read from a new `package` property, `TerminalSession.bytesDrawn`, which the session sets when a draw commits, so the harness needs no lock and no output tap. The counts are the library's work and not a promise, so the corpus bounds them loosely (`foreach-desktop.scn` allows 1,000 bytes for a step that takes 430). (this plan, Slice 6 run) - 2026-10-24 `dump` and the grid golden (owner to weigh). `dump` puts the projection in the step's observation as a `projection` object, with `lines` empty; the projection is the graph's diagnostics as sorted-key JSON (per node: path, parent, children, depth, paint order, short `type` and `layout`, proposal, measured size, frame, clip, dirty flags, environment overrides, handler kinds, requirements, and the leaf's `text`), the statistics and invalidation reasons without durations, and the screen's rows and style runs, at `version` 1. `NAME.grid.txt` is the catalog's grid dialect over that screen: `── chars ──`, `── style ──` (a letter under each covered cell, `.` for the default, trailing default cells left off), and `── legend ──`; colours are `#rrggbb` or `indexed(N)`, and attributes are `bold`, `dim`, `italic`, `underline` (`underline=curly` and the other styles), `reverse`, and `strikethrough`. The virtual terminal exposes no wide flag, so a blank cell right after a width-2 grapheme is read as its continuation; Slice 8's Ghostty cell flag should replace that rule. (this plan, Slice 6 run) - 2026-10-24 Per-primitive demos absorb Slice 5's `stack` demo and are written to be read at 40 columns (owner to weigh). `stacks` replaces `stack` (and `stack-mobile.scn` with its goldens) and shows stacks, `Spacer`, and `layoutPriority`; the other five are `text` (the archived Unicode samples, kept short), `frame-padding`, `zstack`, `flex`, and `foreach`. Each takes a desktop and a mobile scenario that shots both, asserts one frame, and mostly runs `check`; a demo that overflows on purpose (`frame-padding`, `flex`) names the rules it passes, and a demo shows every variable style it has (alignments, padding, priorities, weights). (this plan, Slice 6 run) +- 2026-10-24 Critic review of PR #67, decisions the findings settled (owner to weigh). `siblings-disjoint` looks through view lists (F1): the container that decides whether items overlap is the nearest ancestor that is not a `ForEach`, an `if` (`Optional` or `ConditionalView`), a tuple, or an empty view, so the layers a list or an `if` builds under a `ZStack` are layers and the same items under a stack are not. Lists are matched by type name, so a new list type joins that set, as a new overlay container joins `ZStack`'s. The grid golden has one row rule (F2), pinned before any golden has a style: the chars section ends at the last row with text or a style, with an empty line for a styled blank row, the style section ends at the last row with a style, and `NAME.txt` still ends at the last row with text, so the catalog dialect's "no more style rows than chars rows" holds by construction. A grid has 62 letters, and a screen with more distinct styles fails its `shot` as a harness error (exit 3, in `--record` too), because one letter for two styles would make the golden ambiguous (F3); a longer alphabet can be added later without drifting any golden that fits in 62. `run --pretty` prints a bare `check`'s warnings as `line N: warning RULE NODE: DETAIL` under the scenario's line and before its failures (F4). An empty path, name, or flag value (`--trace ""`, `--app ""`, `run ""`) is a usage error, exit 2 (F5), and so is an empty locator value, since `text=""` would match every leaf and the other keys nothing; `text=` is a substring match, and the README, the Developer tooling page, and the skill say so (F11). A trace record's `version` is read before the rest of the line (F9), so a later schema is named by its version. A live trace records the terminal's focus changes as `focusGained` and `focusLost`, and replay delivers them (the review's open question): the library enables focus events by default and some terminals send a focus-in as soon as the mode is on, which would make every live trace unreplayable. The scenario grammar still has no statement for them, and mouse events and terminal replies stay `other` and are refused by name. The `flex` demo gained a second page (a `layoutPriority` tier, a child with no `.flex` value, cross-axis placement), the `stacks` demo a `Spacer(minLength:)` row and a `Spacer` in a `VStack`, and the `zstack` demo layers from a `ForEach` and an `if` (F6). (this plan, Slice 6 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 @@ -340,6 +341,9 @@ Rules for an agent running slices of this plan without the owner. - As Slice 4 found, a graph rooted at an `@ViewBuilder` body is dirty after every `update()`, so every step of every demo draws (`drew: true`); the cost counters show it, and a step that changes nothing still writes bytes. `no-dirty-after-pass` passes on the corpus because a completed pass clears every flag, and `ChecksTests` crafts a projection that still has a dirty node to prove it fails. The fixtures in `ProjectionFixtures` with an explicit root skip draws (`drew: false`), which is how a clean step's trace record and cost are tested. - No view in the corpus sets a terminal requirement, so `requirements-aggregate` passes vacuously there; its tests use crafted projections, and Slice 10's pointer demo (any-event tracking requested only while a handler needs it) is its first live subject. - Tooling: `mise run quality:format` and `quality:lint` skip `Examples/Tests`, but the pre-commit hook lints every staged Swift file, so format that directory by hand (`scripts/swift-format.sh format --configuration .swift-format -i` and `swiftlint lint --fix`) before committing. A `--scratch-path .build-27` directory is untracked and not ignored, so `git add -A` stages SwiftPM's checkouts as embedded repositories and the hook's stash of untracked files timed out on it; delete the directory before staging. `swift run --package-path Examples` without `--only-use-versions-from-resolved-file` rewrites `Examples/Package.resolved` (the mise tasks pass the flag). +- The layers of a `ForEach` or an `if` under a `ZStack` hang off a list node in the tree, so a rule that reads a node's parent sees `ForEach` where layout sees the `ZStack`: the first `siblings-disjoint` tested only the immediate parent and failed on `ZStack { ForEach … }` and on `ZStack { if … { A; B } }` (critic review of PR #67, F1). A rule that reasons about layout must look through the list nodes (`ForEach`, `Optional`, `ConditionalView`, `TupleView`, `EmptyView`) the way `makeLayoutSubviews` does; `frame-inside-clip` and `measured-within-proposal` read one node and are not affected. +- `Flex` takes no alignment: a child smaller than the cross extent always sits at the leading edge (`VerticalAlignment.top` for a horizontal `Flex`, `HorizontalAlignment.leading` for a vertical one, in `Flex.swift`), where `flex.md` says placement uses "the selected" alignment and the public initializer has no such parameter. The `flex` demo's second page shows what exists, and a scenario pins it (owner to weigh). +- A desktop viewport is 24 rows, so a demo that needs more pages: the `flex` demo's constraints and its priority, default, and cross-axis samples are two pages (`n`), and the `stacks` demo needs 22 rows, 24 with `v` pressed. The `text` demo's wrapped, truncated, and mode-truncated fixtures are not in it because `Text` does not wrap or truncate yet (Slice 8), and `text.md` now says so. ## Outcome From a7d0b5c52931f5894185ccc10f9de9bd5b924a4a Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 07:13:12 -0400 Subject: [PATCH 26/55] fix(harness): decide a view list from its module-qualified type The projection node gains isList, set from the reflected type before it is shortened, so siblings-disjoint looks through the library's ForEach, if, and view groups and not through an app's own view that happens to be named ForEach. The README says a container-less multi-view body is a true positive. --- Examples/Scenarios/README.md | 2 +- .../TesseraHarness/Projection/Checks.swift | 10 +--- .../Projection/Projection.swift | 7 +++ .../TesseraHarness/Projection/TypeName.swift | 15 ++++++ Tests/TesseraHarnessTests/ChecksTests.swift | 51 ++++++++++++++++--- .../ProjectionFixtures.swift | 1 + .../ScenarioStatementsTests.swift | 23 ++++++++- .../ShadowedListFixtures.swift | 28 ++++++++++ Tests/TesseraHarnessTests/TypeNameTests.swift | 31 +++++++++++ 9 files changed, 150 insertions(+), 18 deletions(-) create mode 100644 Tests/TesseraHarnessTests/ShadowedListFixtures.swift diff --git a/Examples/Scenarios/README.md b/Examples/Scenarios/README.md index 1f49f9e..657c541 100644 --- a/Examples/Scenarios/README.md +++ b/Examples/Scenarios/README.md @@ -106,7 +106,7 @@ A selector, written `SEL` above, is a list of `key=value` words that choose node An error fails the statement (exit 1) and names the rule and the node in the failure's `rule` and `node`. A warning does not fail a bare `check`: it is listed in the statement's observation and, in the text form, under the scenario's `PASS` or `FAIL` line as `line N: warning RULE NODE: DETAIL`, because the content it flags can be clipped on purpose. Naming a rule asks for it to hold, so `check frame-inside-clip` fails on a warning. Every `check` writes an observation that names the rules it ran, so a pass is never silent. -A rule can fire by contract. A fixed `frame(width:)` is wider than the width its parent offered, a `Flex` child is wider than its segment, and `Text` does not wrap yet, so it ignores the width it is offered. A scenario that shows such a thing on purpose names the rules that must hold (`check no-dirty-after-pass siblings-disjoint`), and a bare `check` covers an app that clips nothing. `tessera check NAME [SIZE]` runs every rule over an app at a size with no input. +A rule can fire by contract. A fixed `frame(width:)` is wider than the width its parent offered, a `Flex` child is wider than its segment, and `Text` does not wrap yet, so it ignores the width it is offered. A scenario that shows such a thing on purpose names the rules that must hold (`check no-dirty-after-pass siblings-disjoint`), and a bare `check` covers an app that clips nothing. A body of several views with no container around them (`Text("a"); Text("b")` as a view's body or at the root) is placed on one frame by the library's own placement, so `siblings-disjoint` reports it, and that is a true positive: put the views in a stack. Only the library's own lists are looked through, so a view of your own that is named `ForEach` is an ordinary view. `tessera check NAME [SIZE]` runs every rule over an app at a size with no input. ### Cost diff --git a/Sources/TesseraHarness/Projection/Checks.swift b/Sources/TesseraHarness/Projection/Checks.swift index 822e39f..20ce9e2 100644 --- a/Sources/TesseraHarness/Projection/Checks.swift +++ b/Sources/TesseraHarness/Projection/Checks.swift @@ -88,12 +88,6 @@ private let overlappingContainers: Set = ["ZStack"] /// container doing its job. private let scrollingContainers: Set = ["ScrollView"] -/// The node types that hold a view list, which layout looks through: it places a list's -/// elements as though they were children of the list's own parent. -private let listTypes: Set = [ - "ConditionalView", "EmptyView", "ForEach", "Optional", "TupleView", -] - /// One projection, indexed for the rules. private struct CheckContext { let projection: Projection @@ -116,9 +110,7 @@ private struct CheckContext { /// that is not a view list when `node` is one. func placingContainer(of node: Projection.Node) -> Projection.Node { var current = node - while listTypes.contains(current.type), - let parent = current.parent.flatMap({ byPath[$0] }) - { + while current.isList, let parent = current.parent.flatMap({ byPath[$0] }) { current = parent } return current diff --git a/Sources/TesseraHarness/Projection/Projection.swift b/Sources/TesseraHarness/Projection/Projection.swift index 189cdbb..ecbec14 100644 --- a/Sources/TesseraHarness/Projection/Projection.swift +++ b/Sources/TesseraHarness/Projection/Projection.swift @@ -77,6 +77,12 @@ package struct Projection: Encodable, Equatable, Sendable { /// For a `_LayoutContainer` (`frame`, `padding`, an app's own `Layout`), the short name of /// its algorithm; absent for every other node. var layout: String? + + /// Whether this is one of the library's view lists (`ForEach`, `if`, a group of views), + /// which layout looks through to place the elements as the list's parent's own children. + /// It is decided from the module-qualified type, so an app's own view named `ForEach` is + /// not one. + var isList: Bool var proposal: Proposal? var measured: Extent? var frame: Box @@ -248,6 +254,7 @@ extension Projection.Node { self.paintOrder = paintOrder type = TypeName.short(node.viewType) layout = TypeName.layout(of: node.viewType) + isList = TypeName.isViewList(node.viewType) proposal = node.proposal.map { Projection.Proposal(width: $0.width, height: $0.height) } diff --git a/Sources/TesseraHarness/Projection/TypeName.swift b/Sources/TesseraHarness/Projection/TypeName.swift index 8553be0..b03b1b5 100644 --- a/Sources/TesseraHarness/Projection/TypeName.swift +++ b/Sources/TesseraHarness/Projection/TypeName.swift @@ -7,11 +7,26 @@ /// and, for a `_LayoutContainer`, the short `layout` of its algorithm (`_FrameLayout`, /// `_PaddingLayout`, or the name of an app's own `Layout`), and never the reflected name. enum TypeName { + /// The generic-free, module-qualified names of the view lists: the structural views that + /// `_ViewList` marks in `TesseraCore`, and the `Optional` an `if` without an `else` builds. + private static let viewLists: Set = [ + "TesseraCore.ConditionalView", "TesseraCore.EmptyView", "TesseraCore.ForEach", + "TesseraCore.TupleView", "Swift.Optional", + ] + /// `TesseraLayout.VStack>` becomes `VStack`. static func short(_ reflected: String) -> String { lastComponent(of: genericFree(reflected)) } + /// Whether the reflected type is one of the library's view lists, which layout looks through. + /// + /// The module-qualified name decides, before it is shortened: a view of an app's own that + /// happens to be called `ForEach` or `TupleView` is a composite with one child, not a list. + static func isViewList(_ reflected: String) -> Bool { + viewLists.contains(genericFree(reflected)) + } + /// The short name of the algorithm of a `_LayoutContainer`, or `nil` /// for any other type. static func layout(of reflected: String) -> String? { diff --git a/Tests/TesseraHarnessTests/ChecksTests.swift b/Tests/TesseraHarnessTests/ChecksTests.swift index 1c345c4..d0863ee 100644 --- a/Tests/TesseraHarnessTests/ChecksTests.swift +++ b/Tests/TesseraHarnessTests/ChecksTests.swift @@ -16,6 +16,7 @@ struct ChecksTests { children: [String] = [], type: String = "Text", layout: String? = nil, + list: Bool = false, frame: Rect4, clip: Rect4? = nil, proposal: (width: Int?, height: Int?)? = nil, @@ -26,7 +27,7 @@ struct ChecksTests { ) -> Projection.Node { Projection.Node( path: path, parent: parent, children: children, depth: 0, paintOrder: 0, type: type, - layout: layout, + layout: layout, isList: list, proposal: proposal.map { Projection.Proposal(width: $0.width, height: $0.height) }, measured: measured.map { Projection.Extent(columns: $0.columns, rows: $0.rows) }, frame: box(frame), clip: box(clip ?? frame), needsLayout: dirty.layout, @@ -228,7 +229,7 @@ struct ChecksTests { node( "root/index(0)", parent: "root", children: ["root/index(0)/id(a)", "root/index(0)/id(b)"], type: "ForEach", - frame: (0, 0, 4, 1)), + list: true, frame: (0, 0, 4, 1)), node("root/index(0)/id(a)", parent: "root/index(0)", frame: (0, 0, 4, 1)), node("root/index(0)/id(b)", parent: "root/index(0)", frame: (0, 0, 2, 1)), ])) @@ -251,13 +252,13 @@ struct ChecksTests { node("root/index(0)", parent: "root", frame: (0, 0, 4, 1)), node( "root/index(1)", parent: "root", children: ["root/index(1)/branch(true)"], - type: "Optional", frame: (0, 0, 1, 1)), + type: "Optional", list: true, frame: (0, 0, 1, 1)), node( "root/index(1)/branch(true)", parent: "root/index(1)", children: [ "root/index(1)/branch(true)/index(0)", "root/index(1)/branch(true)/index(1)", ], - type: "TupleView", frame: (0, 0, 1, 1)), + type: "TupleView", list: true, frame: (0, 0, 1, 1)), node( "root/index(1)/branch(true)/index(0)", parent: "root/index(1)/branch(true)", frame: (0, 0, 1, 1)), @@ -288,13 +289,13 @@ struct ChecksTests { frame: (0, 0, 4, 2)), node( "root/index(0)", parent: "root", children: ["root/index(0)/branch(false)"], - type: "ConditionalView", frame: (0, 0, 4, 1)), + type: "ConditionalView", list: true, frame: (0, 0, 4, 1)), node( "root/index(0)/branch(false)", parent: "root/index(0)", children: [ "root/index(0)/branch(false)/id(a)", "root/index(0)/branch(false)/id(b)", ], - type: "TupleView", frame: (0, 0, 4, 1)), + type: "TupleView", list: true, frame: (0, 0, 4, 1)), node( "root/index(0)/branch(false)/id(a)", parent: "root/index(0)/branch(false)", frame: (0, 0, 4, 1)), @@ -319,7 +320,7 @@ struct ChecksTests { projection([ node( "root", children: ["root/index(0)", "root/index(1)"], type: "TupleView", - frame: (0, 0, 4, 1)), + list: true, frame: (0, 0, 4, 1)), node("root/index(0)", parent: "root", frame: (0, 0, 4, 1)), node("root/index(1)", parent: "root", frame: (0, 0, 4, 1)), ])) @@ -327,6 +328,42 @@ struct ChecksTests { #expect(found.map(\.node) == ["root/index(1)"]) } + @Test + func `a view of an app's own that is named like a list is not looked through`() { + // The app's `ForEach` is a composite with one child, here the tuple of its body `Text; + // Text`. Layout does not look through a composite, so the two texts overlap by the + // library's own placement, and the rule must report them under `ZStack` as it would + // anywhere else. Only the tuple is the library's list. + func tree(userViewIsList: Bool) -> Projection { + projection([ + node("root", children: ["root/index(0)"], type: "ZStack", frame: (0, 0, 4, 1)), + node( + "root/index(0)", parent: "root", children: ["root/index(0)/index(0)"], + type: "ForEach", list: userViewIsList, frame: (0, 0, 4, 1)), + node( + "root/index(0)/index(0)", parent: "root/index(0)", + children: [ + "root/index(0)/index(0)/index(0)", "root/index(0)/index(0)/index(1)", + ], + type: "TupleView", list: true, frame: (0, 0, 4, 1)), + node( + "root/index(0)/index(0)/index(0)", parent: "root/index(0)/index(0)", + frame: (0, 0, 4, 1)), + node( + "root/index(0)/index(0)/index(1)", parent: "root/index(0)/index(0)", + frame: (0, 0, 2, 1)), + ]) + } + + #expect( + run(.siblingsDisjoint, tree(userViewIsList: false)).map(\.node) == [ + "root/index(0)/index(0)/index(1)" + ]) + #expect( + run(.siblingsDisjoint, tree(userViewIsList: true)).isEmpty, + "the library's own ForEach in that place is a list, and is looked through") + } + @Test func `a custom layout is held to disjoint children under its own name`() { let found = run( diff --git a/Tests/TesseraHarnessTests/ProjectionFixtures.swift b/Tests/TesseraHarnessTests/ProjectionFixtures.swift index 5a68823..85bbc5c 100644 --- a/Tests/TesseraHarnessTests/ProjectionFixtures.swift +++ b/Tests/TesseraHarnessTests/ProjectionFixtures.swift @@ -195,6 +195,7 @@ enum ProjectionFixtures { registry.register("growing", viewports: [.desktop]) { GrowingApp() } registry.register("list-layers", viewports: [.desktop]) { ListLayersApp() } registry.register("list-overlap", viewports: [.desktop]) { ListOverlapApp() } + registry.register("shadowed-list", viewports: [.desktop]) { ShadowedListApp() } return registry } } diff --git a/Tests/TesseraHarnessTests/ScenarioStatementsTests.swift b/Tests/TesseraHarnessTests/ScenarioStatementsTests.swift index b0e0246..1606d41 100644 --- a/Tests/TesseraHarnessTests/ScenarioStatementsTests.swift +++ b/Tests/TesseraHarnessTests/ScenarioStatementsTests.swift @@ -12,7 +12,7 @@ private func parse(_ source: String) throws -> Scenario { extension ProjectionFixtures { static let names = [ "overlap", "overflow", "layers", "styled", "wide", "steady", "growing", "list-layers", - "list-overlap", + "list-overlap", "shadowed-list", ] } @@ -184,6 +184,27 @@ struct NewStatementPlayerTests { #expect(failure.message.contains("under ForEach")) } + @Test + func + `an app's own view named ForEach is not a list, so its overlapping body is reported`() + async throws + { + let report = try await play( + """ + app shadowed-list + expect node type=Text text="second" frame 0,0,6x1 + check siblings-disjoint + """) + + // The app's `ForEach` is not looked through, so the `ZStack` does not excuse the two texts + // that its container-less body puts on one frame. + let failure = try #require(report.failures.first) + #expect(report.failures.count == 1, "\(report)") + #expect(failure.verb == "check") + #expect(failure.rule == "siblings-disjoint") + #expect(failure.message.contains("under TupleView")) + } + @Test func `a violated rule fails the statement with its rule and node`() async throws { let report = try await play("app overlap\ncheck siblings-disjoint") diff --git a/Tests/TesseraHarnessTests/ShadowedListFixtures.swift b/Tests/TesseraHarnessTests/ShadowedListFixtures.swift new file mode 100644 index 0000000..996d18c --- /dev/null +++ b/Tests/TesseraHarnessTests/ShadowedListFixtures.swift @@ -0,0 +1,28 @@ +import TesseraCore +import TesseraLayout +import TesseraRuntime +import TesseraTerminalInput + +/// A view of the app's own that is named like a library list. It is a composite whose body is +/// two views and no container, so layout has no container to place them in and puts them on +/// one frame; nothing about the name makes it a `ForEach`. +/// +/// This file uses no `ForEach` of the library: the name here is the app's. +private struct ForEach: View { + @ViewBuilder var body: some View { + Text("first") + Text("second") + } +} + +/// `ZStack { ForEach() }` with the app's own `ForEach`. A `ZStack` overlaps its layers by +/// contract, but the app's view is one layer, and the two texts in its body are not. +final class ShadowedListApp: Application { + var body: some View { + ZStack { + ForEach() + } + } + + func apply(_ event: InputEvent) {} +} diff --git a/Tests/TesseraHarnessTests/TypeNameTests.swift b/Tests/TesseraHarnessTests/TypeNameTests.swift index 8e70948..7ca659e 100644 --- a/Tests/TesseraHarnessTests/TypeNameTests.swift +++ b/Tests/TesseraHarnessTests/TypeNameTests.swift @@ -44,4 +44,35 @@ struct TypeNameTests { #expect(TypeName.layout(of: "TesseraCore.Text") == nil) #expect(TypeName.layout(of: "TesseraCore._LayoutContainer") == nil) } + + @Test + func `the library's view lists are lists whatever they hold`() { + #expect( + TypeName.isViewList( + "TesseraCore.ForEach, TesseraCore.Text>")) + #expect( + TypeName.isViewList("TesseraCore.TupleView<(TesseraCore.Text, TesseraCore.Text)>")) + #expect( + TypeName.isViewList( + "TesseraCore.ConditionalView")) + #expect(TypeName.isViewList("TesseraCore.EmptyView")) + #expect(TypeName.isViewList("Swift.Optional")) + } + + @Test + func `a view of an app's own with a list's name is not a list`() { + #expect(!TypeName.isViewList("MyApp.ForEach")) + #expect(!TypeName.isViewList("MyApp.TupleView")) + #expect(!TypeName.isViewList("MyApp.Optional")) + #expect(!TypeName.isViewList("MyApp.Outer.EmptyView")) + #expect(!TypeName.isViewList("ForEach")) + } + + @Test + func `a view that only has a list inside its arguments is not a list`() { + #expect( + !TypeName.isViewList("TesseraLayout.VStack>")) + #expect(!TypeName.isViewList("TesseraCore.Text")) + #expect(!TypeName.isViewList("Swift.Array>")) + } } From ad445592ba5a20b42c1176a460681089d93bd194 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 07:13:56 -0400 Subject: [PATCH 27/55] docs(plans): record the module-qualified view list fix for Slice 6 --- docs/plans/phase-4-view-layer-and-developer-tooling.md | 4 ++-- 1 file changed, 2 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 8d7a56f..b4c09ec 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -19,7 +19,7 @@ This plan merges plan 032 (Phases 8-17, its open implementation phases; Phases 1 - [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 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. - [x] 5 Scenario language, runner, the `tessera` CLI, and the CI corpus: `3faa2f4` and `ef4592b` on `phase-4/05-scenarios` (PR #66, stacked on #65); full `mise run core:test` runs 838 tests green on Xcode 26.6 and on Xcode 27.1 (750 on the base; 88 added across the parser, player, runner, golden, report, answerer, and command tests in `TesseraHarnessTests`), `swift test --package-path Examples` runs 25 tests on both toolchains (23 on the base; `ScenarioCorpusTests` added), and `mise run quality:format`, `quality:lint`, and `docs:lint` are green; neither `Package.resolved` changed. Two runs of `tessera run Examples/Scenarios` give the same bytes (`shasum` `55fa9588…` twice, 6,996 bytes of JSON); a parse error under `--json` is a report with `"verb":"parse"` and exit 2; a drifted golden fails without `--record` (exit 1, the first differing row named) and `--record` rewrites it (`(updated)`); and the local CI probe, with `counter-basics.scn`'s `expect text 2` changed to a wrong count, exits 1 with its failure at line 19 and exits 0 once reverted (reports below). CI on `ef4592b` (run 40074331235) and on the plan record `5b4dabf` (run 40075060791) passed all 14 jobs, and every `test` leg ran `tessera run --pretty Examples/Scenarios` and printed `5 scenarios, 5 passed, 0 failed`, `test (windows-latest)` included, with its goldens checked out by Git for Windows' defaults and LF by `.gitattributes`; the first run (`3faa2f4`, run 40073722211) failed only that leg, in the README table test, because a Windows checkout of `README.md` has CRLF endings and a `"\r\n"` is one `Character` (fixed in `ef4592b`). The seven findings of the critic review of PR #66 (the app finishing, case-folded shot names, a CRLF golden that had also drifted, `run --repeat PATH`, the unnamed JSON fields, the heredoc closer, and `check`'s app name) are addressed on top of it, and the PR carries the head's run. -- [x] 6 Projection, locators, invariants, cost, trace, and shots, with the first demos: `6781c57` (the library: a resize's viewport reasons survive `update()`, and `TerminalSession.bytesDrawn`), `10f8484` (the projection, rules, `expect cost`, trace, grid goldens, and the six demos with their 12 scenarios), `b2c3161` (an `expect cost` bound in the `ForEach` scenario), and `a7260a7` (the critic review's fixes: list-looking `siblings-disjoint`, the grid row rule, 62 styles, `run --pretty` warnings, empty arguments, the trace version and focus events, and the Flex, Stacks, and ZStack demo gaps) on `phase-4/06-projection` (PR #67, stacked on #66, merged with `origin/phase-4/05-scenarios` at `1cd10ea`); full `mise run core:test` runs 954 tests green on Xcode 26.6 and on Xcode 27.1 (838 on the base; 113 added in `TesseraHarnessTests` across the type-name, grid, projection, rule, statement, trace, replay, and command tests, and two `bytesDrawn` tests and one resize-reason test in the library), `swift test --package-path Examples` runs 39 tests on both toolchains (25 on the base; `PrimitiveDemoTests` added and the corpus floor raised to 16), and `mise run quality:format`, `quality:lint`, and `docs:lint` are green (the pre-commit hook's lint of `Examples/Tests` too); neither `Package.resolved` changed, and `git diff --name-only origin/phase-4/05-scenarios...HEAD` lists none. `mise run dev:run Examples/Scenarios` runs 16 scenarios, 16 passed, and two runs give the same bytes (`shasum` `968b46d6…` twice, 34,563 bytes of JSON; `--repeat 3` agrees); `siblings-disjoint` passes on the `foreach` demo after items are added, removed, and reversed (`check` after each step of `foreach-desktop.scn`), and on `zstack` with layers from a `ForEach` and an `if` (it fails there with the list-looking removed). `tessera check foreach` exits 0 and names its five rules; `tessera check workspace mobileScreen` exits 1 with `error measured-within-proposal root/index(2)/id(catalog)/index(0)/index(0): measured 7 columns where 0 were offered` (Discoveries); tightening `foreach-desktop.scn`'s `expect cost bytes<=1000` to `<=100` fails with `step 1 (key +) took 430 bytes, above the bound 100`. `tessera run --trace` writes five records for `foreach-desktop.scn`, and every redraw names its reasons (`layoutEnvironmentChanged`, `layoutViewChanged`, `renderEnvironmentChanged`, `renderRequested`, `renderViewChanged`, `updateRequested`), with 2,577 bytes for the first frame and 430, 81, 473, and 457 for the four steps after it. CI on `b2c3161` (run 40083068340) and on the plan record `5b8b4c1` (run 40083937741) passed all 14 jobs, and every `test` leg printed `16 scenarios, 16 passed, 0 failed`, `test (windows-latest)` included, with the new grid goldens checked out as LF; the PR carries the head's run after the critic fixes. +- [x] 6 Projection, locators, invariants, cost, trace, and shots, with the first demos: `6781c57` (the library: a resize's viewport reasons survive `update()`, and `TerminalSession.bytesDrawn`), `10f8484` (the projection, rules, `expect cost`, trace, grid goldens, and the six demos with their 12 scenarios), `b2c3161` (an `expect cost` bound in the `ForEach` scenario), `a7260a7` (the critic review's fixes: list-looking `siblings-disjoint`, the grid row rule, 62 styles, `run --pretty` warnings, empty arguments, the trace version and focus events, and the Flex, Stacks, and ZStack demo gaps), and `a7d0b5c` (the re-check's fix: a view list is recognised by its module-qualified type, so an app's own `ForEach` is not one) on `phase-4/06-projection` (PR #67, stacked on #66, merged with `origin/phase-4/05-scenarios` at `1cd10ea`); full `mise run core:test` runs 959 tests green on Xcode 26.6 and on Xcode 27.1 (838 on the base; 118 added in `TesseraHarnessTests` across the type-name, grid, projection, rule, statement, trace, replay, and command tests, and two `bytesDrawn` tests and one resize-reason test in the library), `swift test --package-path Examples` runs 39 tests on both toolchains (25 on the base; `PrimitiveDemoTests` added and the corpus floor raised to 16), and `mise run quality:format`, `quality:lint`, and `docs:lint` are green (the pre-commit hook's lint of `Examples/Tests` too); neither `Package.resolved` changed, and `git diff --name-only origin/phase-4/05-scenarios...HEAD` lists none. `mise run dev:run Examples/Scenarios` runs 16 scenarios, 16 passed, and two runs give the same bytes (`shasum` `968b46d6…` twice, 34,563 bytes of JSON; `--repeat 3` agrees); `siblings-disjoint` passes on the `foreach` demo after items are added, removed, and reversed (`check` after each step of `foreach-desktop.scn`), and on `zstack` with layers from a `ForEach` and an `if` (it fails there with the list-looking removed), while an app's own view named `ForEach` whose body is `Text; Text` under a `ZStack` is reported (a live test that fails under short-name matching). `tessera check foreach` exits 0 and names its five rules; `tessera check workspace mobileScreen` exits 1 with `error measured-within-proposal root/index(2)/id(catalog)/index(0)/index(0): measured 7 columns where 0 were offered` (Discoveries); tightening `foreach-desktop.scn`'s `expect cost bytes<=1000` to `<=100` fails with `step 1 (key +) took 430 bytes, above the bound 100`. `tessera run --trace` writes five records for `foreach-desktop.scn`, and every redraw names its reasons (`layoutEnvironmentChanged`, `layoutViewChanged`, `renderEnvironmentChanged`, `renderRequested`, `renderViewChanged`, `updateRequested`), with 2,577 bytes for the first frame and 430, 81, 473, and 457 for the four steps after it. CI on `b2c3161` (run 40083068340), on the plan record `5b8b4c1` (run 40083937741), and on the head after the critic fixes `6ea1bd5` (run 40087437615) passed all 14 jobs, and every `test` leg printed `16 scenarios, 16 passed, 0 failed`, `test (windows-latest)` included, with the new grid goldens checked out as LF; the PR carries the head's run after the re-check's fix. - [ ] 7 Flow layout and `ViewThatFits` - [ ] 8 Styling and decoration, with pictures, roles, and catalog review - [ ] 9 Focus, key routing, and KeyLegend, with focus tooling @@ -290,7 +290,7 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-24 `expect cost NAME<=N…` bounds the last step the scenario took, and only with `<=` (owner to weigh). The counters are `bodies`, `bytes`, `measures`, `placements`, and `renders`; the step is the last event delivered, so the statement can follow a `shot` or an `expect`; a failure prints the whole record (`bodies=0 bytes=430 measures=143 placements=34 renders=18`). `bytes` is the size of the step's frame, read from a new `package` property, `TerminalSession.bytesDrawn`, which the session sets when a draw commits, so the harness needs no lock and no output tap. The counts are the library's work and not a promise, so the corpus bounds them loosely (`foreach-desktop.scn` allows 1,000 bytes for a step that takes 430). (this plan, Slice 6 run) - 2026-10-24 `dump` and the grid golden (owner to weigh). `dump` puts the projection in the step's observation as a `projection` object, with `lines` empty; the projection is the graph's diagnostics as sorted-key JSON (per node: path, parent, children, depth, paint order, short `type` and `layout`, proposal, measured size, frame, clip, dirty flags, environment overrides, handler kinds, requirements, and the leaf's `text`), the statistics and invalidation reasons without durations, and the screen's rows and style runs, at `version` 1. `NAME.grid.txt` is the catalog's grid dialect over that screen: `── chars ──`, `── style ──` (a letter under each covered cell, `.` for the default, trailing default cells left off), and `── legend ──`; colours are `#rrggbb` or `indexed(N)`, and attributes are `bold`, `dim`, `italic`, `underline` (`underline=curly` and the other styles), `reverse`, and `strikethrough`. The virtual terminal exposes no wide flag, so a blank cell right after a width-2 grapheme is read as its continuation; Slice 8's Ghostty cell flag should replace that rule. (this plan, Slice 6 run) - 2026-10-24 Per-primitive demos absorb Slice 5's `stack` demo and are written to be read at 40 columns (owner to weigh). `stacks` replaces `stack` (and `stack-mobile.scn` with its goldens) and shows stacks, `Spacer`, and `layoutPriority`; the other five are `text` (the archived Unicode samples, kept short), `frame-padding`, `zstack`, `flex`, and `foreach`. Each takes a desktop and a mobile scenario that shots both, asserts one frame, and mostly runs `check`; a demo that overflows on purpose (`frame-padding`, `flex`) names the rules it passes, and a demo shows every variable style it has (alignments, padding, priorities, weights). (this plan, Slice 6 run) -- 2026-10-24 Critic review of PR #67, decisions the findings settled (owner to weigh). `siblings-disjoint` looks through view lists (F1): the container that decides whether items overlap is the nearest ancestor that is not a `ForEach`, an `if` (`Optional` or `ConditionalView`), a tuple, or an empty view, so the layers a list or an `if` builds under a `ZStack` are layers and the same items under a stack are not. Lists are matched by type name, so a new list type joins that set, as a new overlay container joins `ZStack`'s. The grid golden has one row rule (F2), pinned before any golden has a style: the chars section ends at the last row with text or a style, with an empty line for a styled blank row, the style section ends at the last row with a style, and `NAME.txt` still ends at the last row with text, so the catalog dialect's "no more style rows than chars rows" holds by construction. A grid has 62 letters, and a screen with more distinct styles fails its `shot` as a harness error (exit 3, in `--record` too), because one letter for two styles would make the golden ambiguous (F3); a longer alphabet can be added later without drifting any golden that fits in 62. `run --pretty` prints a bare `check`'s warnings as `line N: warning RULE NODE: DETAIL` under the scenario's line and before its failures (F4). An empty path, name, or flag value (`--trace ""`, `--app ""`, `run ""`) is a usage error, exit 2 (F5), and so is an empty locator value, since `text=""` would match every leaf and the other keys nothing; `text=` is a substring match, and the README, the Developer tooling page, and the skill say so (F11). A trace record's `version` is read before the rest of the line (F9), so a later schema is named by its version. A live trace records the terminal's focus changes as `focusGained` and `focusLost`, and replay delivers them (the review's open question): the library enables focus events by default and some terminals send a focus-in as soon as the mode is on, which would make every live trace unreplayable. The scenario grammar still has no statement for them, and mouse events and terminal replies stay `other` and are refused by name. The `flex` demo gained a second page (a `layoutPriority` tier, a child with no `.flex` value, cross-axis placement), the `stacks` demo a `Spacer(minLength:)` row and a `Spacer` in a `VStack`, and the `zstack` demo layers from a `ForEach` and an `if` (F6). (this plan, Slice 6 run) +- 2026-10-24 Critic review of PR #67, decisions the findings settled (owner to weigh). `siblings-disjoint` looks through view lists (F1): the container that decides whether items overlap is the nearest ancestor that is not a `ForEach`, an `if` (`Optional` or `ConditionalView`), a tuple, or an empty view, so the layers a list or an `if` builds under a `ZStack` are layers and the same items under a stack are not. A list is recognised by its module-qualified type (`TesseraCore.ForEach`, `Swift.Optional`, and so on, decided in `TypeName` before the name is shortened and carried as the projection node's `isList`), so an app's own view named `ForEach` is an ordinary composite and is not looked through, and a new list type joins that set; the overlay and scrolling containers (`ZStack`, `ScrollView`) are still matched by their short name, so a view of an app's own named `ZStack` would be excused the same way, which only matters for a container-less body under it (owner to weigh). A body of several views with no container around them overlaps by the library's own placement, so `siblings-disjoint` reports it as a true positive, and the README says so. The grid golden has one row rule (F2), pinned before any golden has a style: the chars section ends at the last row with text or a style, with an empty line for a styled blank row, the style section ends at the last row with a style, and `NAME.txt` still ends at the last row with text, so the catalog dialect's "no more style rows than chars rows" holds by construction. A grid has 62 letters, and a screen with more distinct styles fails its `shot` as a harness error (exit 3, in `--record` too), because one letter for two styles would make the golden ambiguous (F3); a longer alphabet can be added later without drifting any golden that fits in 62. `run --pretty` prints a bare `check`'s warnings as `line N: warning RULE NODE: DETAIL` under the scenario's line and before its failures (F4). An empty path, name, or flag value (`--trace ""`, `--app ""`, `run ""`) is a usage error, exit 2 (F5), and so is an empty locator value, since `text=""` would match every leaf and the other keys nothing; `text=` is a substring match, and the README, the Developer tooling page, and the skill say so (F11). A trace record's `version` is read before the rest of the line (F9), so a later schema is named by its version. A live trace records the terminal's focus changes as `focusGained` and `focusLost`, and replay delivers them (the review's open question): the library enables focus events by default and some terminals send a focus-in as soon as the mode is on, which would make every live trace unreplayable. The scenario grammar still has no statement for them, and mouse events and terminal replies stay `other` and are refused by name. The `flex` demo gained a second page (a `layoutPriority` tier, a child with no `.flex` value, cross-axis placement), the `stacks` demo a `Spacer(minLength:)` row and a `Spacer` in a `VStack`, and the `zstack` demo layers from a `ForEach` and an `if` (F6). (this plan, Slice 6 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 From 8eb14666c961b9c8f9bff5be94bbb586b143bdd4 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 12:04:54 -0400 Subject: [PATCH 28/55] fix(harness): address review feedback on the runtime and harness Sort the root and Examples manifests alphabetically, state enduring facts in comments and DocC instead of plan numbers, conform Locator and FrameExpectation to CustomStringConvertible, say what Application's class-only contract means, express the scenario tests as snapshots, and record the owner's approval of the Slice 3 decisions. --- CHANGELOG.md | 4 +- Examples/Package.swift | 34 +- Package.swift | 146 ++++---- Sources/TesseraHarness/Demos.swift | 2 +- Sources/TesseraHarness/Driver.swift | 6 +- .../TesseraHarness/Scenarios/Locator.swift | 4 +- .../TesseraHarness/Scenarios/Scenario.swift | 10 +- Sources/TesseraRuntime/Application.swift | 6 +- .../TesseraRuntime.docc/TesseraRuntime.md | 2 +- Tests/TesseraHarnessTests/ScenarioTests.swift | 322 +++++++++++++----- ...hase-4-view-layer-and-developer-tooling.md | 14 +- docs/spec/phase-5-runtime-and-polish.md | 2 +- spikes/HeadlessRunner/README.md | 2 +- spikes/README.md | 2 +- 14 files changed, 360 insertions(+), 196 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36795a1..c0a78ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +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 the `TesseraRuntime` module with the `Application` protocol, re-exported by `Tessera`: a reference type that owns its model, whose `body` is the root view, whose `apply(_:)` receives each event the view graph left unhandled and every resize, and whose `isFinished` (default `false`) tells a host to stop. It is class-only, inheriting from `AnyObject` and no other protocol, so plain classes, `@Observable` models, default-main-actor types, and `@MainActor` models (through `: @MainActor Application`) all adopt it where the type is declared. +- Added the `TesseraHarness` library product, the first home of the developer tooling: the scenario parser, runner, and report that the HeadlessRunner spike proved, ported with its scenario corpus and tests as `package` code. It declares no public API. - Added 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/Examples/Package.swift b/Examples/Package.swift index 14b222f..c2e1ae5 100644 --- a/Examples/Package.swift +++ b/Examples/Package.swift @@ -138,38 +138,38 @@ package.targets.append( ) ) -// MARK: Phase3ProtocolsDemoSupport +// MARK: Phase3ProtocolsDemo -package.targets.append( - .target( - name: "Phase3ProtocolsDemoSupport", - dependencies: [ - TesseraTerminal - ] - ) +package.products.append( + .executable(name: "Phase3ProtocolsDemo", targets: ["Phase3ProtocolsDemo"]) ) package.targets.append( - .testTarget( - name: "Phase3ProtocolsDemoSupportTests", + .executableTarget( + name: "Phase3ProtocolsDemo", dependencies: [ + ExampleSupport, Phase3ProtocolsDemoSupport, TesseraTerminal, ] ) ) -// MARK: Phase3ProtocolsDemo +// MARK: Phase3ProtocolsDemoSupport -package.products.append( - .executable(name: "Phase3ProtocolsDemo", targets: ["Phase3ProtocolsDemo"]) +package.targets.append( + .target( + name: "Phase3ProtocolsDemoSupport", + dependencies: [ + TesseraTerminal + ] + ) ) package.targets.append( - .executableTarget( - name: "Phase3ProtocolsDemo", + .testTarget( + name: "Phase3ProtocolsDemoSupportTests", dependencies: [ - ExampleSupport, Phase3ProtocolsDemoSupport, TesseraTerminal, ] @@ -198,10 +198,10 @@ for target in package.targets { } var settings = target.swiftSettings ?? [] settings.append(contentsOf: [ - .enableExperimentalFeature("Lifetimes"), .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("ForwardFromBuilder"), .enableUpcomingFeature("InferIsolatedConformances"), + .enableExperimentalFeature("Lifetimes"), .enableUpcomingFeature("NonisolatedNonsendingByDefault"), ]) target.swiftSettings = settings diff --git a/Package.swift b/Package.swift index b27de06..0abc685 100644 --- a/Package.swift +++ b/Package.swift @@ -20,18 +20,18 @@ let package = Package( // MARK: - ⤵️ Dependencies -// MARK: IssueReporting +// MARK: CustomDump package.dependencies.append( .package( - url: "https://github.com/pointfreeco/xctest-dynamic-overlay", - from: "1.4.0" + url: "https://github.com/pointfreeco/swift-custom-dump", + from: "1.0.0" ) ) -let IssueReporting: Target.Dependency = .product( - name: "IssueReporting", - package: "xctest-dynamic-overlay" +let CustomDump: Target.Dependency = .product( + name: "CustomDump", + package: "swift-custom-dump" ) // MARK: DisplayWidth @@ -48,20 +48,6 @@ let DisplayWidth: Target.Dependency = .product( package: "swift-displaywidth" ) -// MARK: CustomDump - -package.dependencies.append( - .package( - url: "https://github.com/pointfreeco/swift-custom-dump", - from: "1.0.0" - ) -) - -let CustomDump: Target.Dependency = .product( - name: "CustomDump", - package: "swift-custom-dump" -) - // MARK: DocC #if !os(Windows) @@ -73,6 +59,20 @@ let CustomDump: Target.Dependency = .product( ) #endif +// MARK: IssueReporting + +package.dependencies.append( + .package( + url: "https://github.com/pointfreeco/xctest-dynamic-overlay", + from: "1.4.0" + ) +) + +let IssueReporting: Target.Dependency = .product( + name: "IssueReporting", + package: "xctest-dynamic-overlay" +) + // MARK: SnapshotTesting package.dependencies.append( @@ -119,11 +119,9 @@ let CTesseraTerminalPlatform: Target.Dependency = .byName( ) let Tessera: Target.Dependency = .byName(name: "Tessera") let TesseraCore: Target.Dependency = .byName(name: "TesseraCore") -let TesseraTestSupport: Target.Dependency = .byName(name: "TesseraTestSupport") +let TesseraHarness: Target.Dependency = .byName(name: "TesseraHarness") 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") @@ -139,15 +137,15 @@ let TesseraTerminalSnapshotSupport: Target.Dependency = .byName( let TesseraTerminalTestSupport: Target.Dependency = .byName( name: "TesseraTerminalTestSupport" ) +let TesseraTestSupport: Target.Dependency = .byName(name: "TesseraTestSupport") +let TesseraWidgets: Target.Dependency = .byName(name: "TesseraWidgets") let AllTesseraTargetNames: Set = [ "Tessera", "TesseraCore", + "TesseraHarness", "TesseraLayout", - "TesseraTestSupport", - "TesseraWidgets", "TesseraRuntime", - "TesseraHarness", "TesseraTerminal", "TesseraTerminalANSI", "TesseraTerminalBuffer", @@ -157,6 +155,8 @@ let AllTesseraTargetNames: Set = [ "TesseraTerminalRendering", "TesseraTerminalSnapshotSupport", "TesseraTerminalTestSupport", + "TesseraTestSupport", + "TesseraWidgets", ] // MARK: - 🎯 Products & Targets @@ -177,6 +177,16 @@ package.targets.append( ) ) +// MARK: CTesseraTerminalPlatform + +package.targets.append( + .target( + name: "CTesseraTerminalPlatform", + path: "Sources/CTesseraTerminalPlatform", + publicHeadersPath: "include" + ) +) + // MARK: GhosttyVTLibrary // libghostty-vt for the Ghostty revision in scripts/ghostty-vt-version.txt, as an SE-0482 @@ -205,16 +215,6 @@ if ProcessInfo.processInfo.environment["TESSERA_GHOSTTY_VT_BUNDLE"] == "local" { ) } -// MARK: CTesseraTerminalPlatform - -package.targets.append( - .target( - name: "CTesseraTerminalPlatform", - path: "Sources/CTesseraTerminalPlatform", - publicHeadersPath: "include" - ) -) - // MARK: Tessera package.products.append(.library(name: "Tessera", targets: ["Tessera"])) @@ -226,8 +226,8 @@ package.targets.append( TesseraCore, TesseraLayout, TesseraRuntime, - TesseraWidgets, TesseraTerminal, + TesseraWidgets, ] ) ) @@ -254,56 +254,63 @@ package.targets.append(contentsOf: [ dependencies: [ InlineSnapshotTesting, TesseraCore, - TesseraTestSupport, TesseraTerminalANSI, TesseraTerminalBuffer, TesseraTerminalCore, + TesseraTestSupport, ] ), ]) -// MARK: TesseraLayout +// MARK: TesseraHarness + +package.products.append(.library(name: "TesseraHarness", targets: ["TesseraHarness"])) package.targets.append(contentsOf: [ .target( - name: "TesseraLayout", + name: "TesseraHarness", dependencies: [ - TesseraCore + TesseraCore, + TesseraLayout, + TesseraRuntime, + TesseraTerminalCore, + TesseraTerminalInput, + TesseraTerminalSnapshotSupport, + TesseraWidgets, ] ), .testTarget( - name: "TesseraLayoutTests", + name: "TesseraHarnessTests", dependencies: [ + CustomDump, InlineSnapshotTesting, - TesseraCore, - TesseraLayout, - TesseraTestSupport, - TesseraTerminalBuffer, - TesseraTerminalCore, - ] + SnapshotTesting, + SnapshotTestingCustomDump, + TesseraHarness, + TesseraTerminalInput, + ], + exclude: ["Scenarios"] ), ]) -// MARK: TesseraWidgets +// MARK: TesseraLayout package.targets.append(contentsOf: [ .target( - name: "TesseraWidgets", + name: "TesseraLayout", dependencies: [ - TesseraCore, - TesseraLayout, + TesseraCore ] ), .testTarget( - name: "TesseraWidgetsTests", + name: "TesseraLayoutTests", dependencies: [ InlineSnapshotTesting, TesseraCore, TesseraLayout, - TesseraTestSupport, TesseraTerminalBuffer, TesseraTerminalCore, - TesseraWidgets, + TesseraTestSupport, ] ), ]) @@ -539,11 +546,11 @@ package.targets.append( dependencies: [ CustomDump, SnapshotTesting, + TesseraTerminal, TesseraTerminalBuffer, TesseraTerminalInput, TesseraTerminalIO, TesseraTerminalSnapshotSupport, - TesseraTerminal, ] ) ) @@ -568,30 +575,27 @@ package.targets.append( ) ) -// MARK: TesseraHarness - -package.products.append(.library(name: "TesseraHarness", targets: ["TesseraHarness"])) +// MARK: TesseraWidgets package.targets.append(contentsOf: [ .target( - name: "TesseraHarness", + name: "TesseraWidgets", dependencies: [ TesseraCore, TesseraLayout, - TesseraRuntime, - TesseraTerminalCore, - TesseraTerminalInput, - TesseraTerminalSnapshotSupport, - TesseraWidgets, ] ), .testTarget( - name: "TesseraHarnessTests", + name: "TesseraWidgetsTests", dependencies: [ - TesseraHarness, - TesseraTerminalInput, - ], - exclude: ["Scenarios"] + InlineSnapshotTesting, + TesseraCore, + TesseraLayout, + TesseraTerminalBuffer, + TesseraTerminalCore, + TesseraTestSupport, + TesseraWidgets, + ] ), ]) @@ -603,10 +607,10 @@ for target in package.targets { } var settings = target.swiftSettings ?? [] settings.append(contentsOf: [ - .enableExperimentalFeature("Lifetimes"), .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("ForwardFromBuilder"), .enableUpcomingFeature("InferIsolatedConformances"), + .enableExperimentalFeature("Lifetimes"), .enableUpcomingFeature("NonisolatedNonsendingByDefault"), ]) target.swiftSettings = settings diff --git a/Sources/TesseraHarness/Demos.swift b/Sources/TesseraHarness/Demos.swift index 45d50ae..dd51c9e 100644 --- a/Sources/TesseraHarness/Demos.swift +++ b/Sources/TesseraHarness/Demos.swift @@ -5,7 +5,7 @@ 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. +/// status line. All input state is demo-owned because `ViewGraph.dispatch` routes no input to views. package final class WorkspaceApp: DemoApp { package static let name = "workspace" diff --git a/Sources/TesseraHarness/Driver.swift b/Sources/TesseraHarness/Driver.swift index b29d34d..7509337 100644 --- a/Sources/TesseraHarness/Driver.swift +++ b/Sources/TesseraHarness/Driver.swift @@ -5,9 +5,9 @@ import TesseraTerminalSnapshotSupport /// An application the headless runner can drive. /// -/// `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`. +/// `ViewGraph.dispatch` routes no input and returns `.ignored` for every event, so input +/// never reaches views. The app therefore owns its model: ``apply(_:)`` receives each +/// event after `graph.dispatch` returned `.ignored`, as an `Application` host does. package protocol DemoApp: AnyObject { associatedtype Root: View diff --git a/Sources/TesseraHarness/Scenarios/Locator.swift b/Sources/TesseraHarness/Scenarios/Locator.swift index 4e348c6..eebbde3 100644 --- a/Sources/TesseraHarness/Scenarios/Locator.swift +++ b/Sources/TesseraHarness/Scenarios/Locator.swift @@ -5,7 +5,7 @@ import TesseraTerminalCore /// /// 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 { +package struct Locator: CustomStringConvertible, Equatable, Sendable { struct Failure: Error { var message: String } @@ -23,7 +23,7 @@ package struct Locator: Equatable, Sendable { package var text: String? package var nth: Int? - var description: String { + package var description: String { var terms: [String] = [] if let type { terms.append("type=\(type)") } if let id { terms.append("id=\(id)") } diff --git a/Sources/TesseraHarness/Scenarios/Scenario.swift b/Sources/TesseraHarness/Scenarios/Scenario.swift index d8dac6f..bb384a4 100644 --- a/Sources/TesseraHarness/Scenarios/Scenario.swift +++ b/Sources/TesseraHarness/Scenarios/Scenario.swift @@ -18,13 +18,13 @@ package enum Viewport: Equatable, Sendable { } /// A rectangle written as `x,y,wxh` in `expect node … frame`. -package struct FrameExpectation: Equatable, Sendable { +package struct FrameExpectation: CustomStringConvertible, Equatable, Sendable { package var column: Int package var row: Int package var columns: Int package var rows: Int - var description: String { + package var description: String { "\(column),\(row),\(columns)x\(rows)" } } @@ -69,7 +69,7 @@ package struct Scenario: Equatable, Sendable { } /// A fail-fast parse error that renders as `FILE:LINE: error: message`. -package struct ScenarioParseError: Error, Equatable, Sendable, CustomStringConvertible { +package struct ScenarioParseError: CustomStringConvertible, Equatable, Error, Sendable { package var file: String package var line: Int package var message: String @@ -92,7 +92,7 @@ package struct ScenarioParseError: Error, Equatable, Sendable, CustomStringConve /// 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. + /// Verbs the grammar reserves but does not execute; named so the error is specific. static let deferredVerbs: Set = [ "bytes", "check", "dump", "focus", "mouse", "paste", "wait", ] @@ -288,7 +288,7 @@ package enum ScenarioParser { return (columns, rows) } - /// Accepts `X,Y,WxH` (one token) or the plan's `X,Y WxH` (two tokens). + /// Accepts `X,Y,WxH` (one token) or `X,Y WxH` (two tokens). static func parseFrame(_ tokens: [String]) -> FrameExpectation? { let joined = tokens.joined(separator: ",") let parts = joined.split(separator: ",", omittingEmptySubsequences: false) diff --git a/Sources/TesseraRuntime/Application.swift b/Sources/TesseraRuntime/Application.swift index 87f4a04..b993952 100644 --- a/Sources/TesseraRuntime/Application.swift +++ b/Sources/TesseraRuntime/Application.swift @@ -12,9 +12,9 @@ import TesseraTerminalInput /// /// ## 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 +/// `Application` is class-only: it inherits from `AnyObject` and from no other protocol. It +/// is not `@MainActor`, and it does not inherit from `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. diff --git a/Sources/TesseraRuntime/TesseraRuntime.docc/TesseraRuntime.md b/Sources/TesseraRuntime/TesseraRuntime.docc/TesseraRuntime.md index 5eb0d2b..fed572b 100644 --- a/Sources/TesseraRuntime/TesseraRuntime.docc/TesseraRuntime.md +++ b/Sources/TesseraRuntime/TesseraRuntime.docc/TesseraRuntime.md @@ -4,7 +4,7 @@ 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. +``Application`` is the one declaration this module makes: a reference type that owns its model and describes its interface as a view. A host, such as the developer-tools harness, constructs an application, renders its `body`, and delivers input events to it. Import `Tessera` to adopt it: `Tessera` re-exports this module, so one import declares a conforming type. diff --git a/Tests/TesseraHarnessTests/ScenarioTests.swift b/Tests/TesseraHarnessTests/ScenarioTests.swift index d9d33fa..11fc458 100644 --- a/Tests/TesseraHarnessTests/ScenarioTests.swift +++ b/Tests/TesseraHarnessTests/ScenarioTests.swift @@ -1,9 +1,26 @@ +import CustomDump import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump import TesseraTerminalInput import Testing @testable import TesseraHarness +/// The text of the parse error `source` raises, in the `FILE:LINE: error: message` form. +private func parseFailure(of source: String) -> String { + do { + _ = try ScenarioParser.parse(source, file: "t.scn") + Issue.record("`\(source)` parsed without an error") + } catch let error as ScenarioParseError { + return error.description + } catch { + Issue.record(error, "`\(source)` raised a different error") + } + return "" +} + struct ScenarioParserTests { @Test func `header statements bind before the first body statement`() throws { @@ -17,48 +34,57 @@ struct ScenarioParserTests { 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) + assertInlineSnapshot(of: scenario, as: .customDump) { + """ + Scenario( + app: "stack", + viewport: .mobile, + lines: [ + [0]: ScenarioLine( + number: 4, + source: #"type "hi there" # trailing comment"#, + statement: .type("hi there") + ) + ] + ) + """ + } } @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") + assertInlineSnapshot(of: parseFailure(of: "key Tab\nsize 10x10"), as: .lines) { + """ + t.scn:2: error: `size` is a header statement; use `resize WxH` after the first body statement + """ } - - #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") + assertInlineSnapshot(of: parseFailure(of: "jump 3"), as: .lines) { + """ + t.scn:1: error: unknown statement `jump`; accepted: app, size, resize, key, type, shot, tree, expect text, expect node + """ } - - #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") + assertInlineSnapshot(of: parseFailure(of: "mouse click 3,4"), as: .lines) { + """ + t.scn:1: error: `mouse` is not yet expressible in v0; accepted: app, size, resize, key, type, shot, tree, expect text, expect node + """ } - - #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") + assertInlineSnapshot(of: parseFailure(of: "key Hyperspace"), as: .lines) { + """ + t.scn:1: error: unknown key `Hyperspace`; accepted: ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Backspace, Delete, End, Enter, Escape, Home, PageDown, PageUp, Space, Tab, F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, a single character; modifiers: Alt, Control, Ctrl, Meta, Shift, Super joined with `+` + """ } - - #expect(error?.message.contains("ArrowLeft") == true) } @Test @@ -67,63 +93,93 @@ struct ScenarioParserTests { "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)) + expectNoDifference( + scenario.lines.map(\.statement), + [ + .key(Key(code: .tab, modifiers: .shift)), + .key(Key(code: .character("c"), modifiers: .control)), + .key(Key(code: .function(5))), + .key(Key(code: .character("+"), modifiers: .control)), + ] + ) } @Test - func `frame expectations accept one token or the plan's two-token form`() throws { + func `frame expectations accept one token or a two-token form`() throws { let scenario = try ScenarioParser.parse( "expect node id=root frame 1,2,3x4\nexpect node id=root frame 1,2 3x4", file: "t.scn" ) - #expect(scenario.lines[0].statement == scenario.lines[1].statement) + let expected = Statement.expectNode( + Locator(id: "root"), + FrameExpectation(column: 1, row: 2, columns: 3, rows: 4) + ) + + expectNoDifference(scenario.lines.map(\.statement), [expected, expected]) } @Test func `unterminated quotes fail with the line number`() { - let error = #expect(throws: ScenarioParseError.self) { - try ScenarioParser.parse("\ntype \"oops", file: "t.scn") + assertInlineSnapshot(of: parseFailure(of: "\ntype \"oops"), as: .lines) { + """ + t.scn:2: error: unterminated double quote + """ } - - #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") + assertInlineSnapshot(of: parseFailure(of: "shot a\nshot a"), as: .lines) { + """ + t.scn:2: error: duplicate shot name `a` + """ } } } struct ScenarioRunnerTests { + private func run(_ source: String) throws -> ScenarioReport { + ScenarioRunner.run(try ScenarioParser.parse(source, file: "t.scn"), name: "t.scn") + } + @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 = try run("app stack\nsize 40x10\nexpect text 0 \"Stack demos\"") - 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") + assertInlineSnapshot(of: report, as: .customDump) { + """ + ScenarioReport( + scenario: "t.scn", + passed: false, + failures: [ + [0]: ScenarioFailure( + line: 3, + statement: #"expect text 0 "Stack demos""#, + verb: "expect text", + message: "row 0 text differs", + expected: "Stack demos", + actual: "Stack demo", + screen: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: -", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ] + ) + ], + observations: [] + ) + """ + } } @Test func `the run continues after a failed expectation`() throws { - let scenario = try ScenarioParser.parse( + let report = try run( """ app stack size 40x10 @@ -131,59 +187,114 @@ 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]) + assertInlineSnapshot(of: report.human(), as: .lines) { + """ + FAIL t.scn + line 3: expect text 0 "wrong" + row 0 text differs + expected: wrong + actual: Stack demo + screen: + 0| Stack demo + 1| ──────────────────────────────────────── + 2| Name: - + 3| left right + 4| ──────────────────────────────────────── + 5| 40 columns + line 6: expect text 5 "wrong again" + row 5 text differs + expected: wrong again + actual: 40 columns + screen: + 0| Stack demo + 1| ──────────────────────────────────────── + 2| Name: x + 3| left right + 4| ──────────────────────────────────────── + 5| 40 columns + """ + } } @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") + let report = try run("app stack\nsize 40x10\nexpect node type=Missing frame 0,0,1x1") - #expect(report.failures.first?.actual?.contains("VStack") == true) + assertInlineSnapshot(of: report.human(), as: .lines) { + """ + FAIL t.scn + line 3: expect node type=Missing frame 0,0,1x1 + locator `type=Missing` matches no node + expected: 0,0,1x1 + actual: (no node; types: VStack, Text, Divider, HStack, Spacer) + screen: + 0| Stack demo + 1| ──────────────────────────────────────── + 2| Name: - + 3| left right + 4| ──────────────────────────────────────── + 5| 40 columns + """ + } } @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 = try run("app stack\nsize 40x10\nexpect node type=Text frame 0,0,1x1") - let report = ScenarioRunner.run(scenario, name: "t.scn") - - #expect(report.failures.first?.message.contains("add nth=") == true) + assertInlineSnapshot(of: report.human(), as: .lines) { + """ + FAIL t.scn + line 3: expect node type=Text frame 0,0,1x1 + locator `type=Text` matches 6 nodes; add nth= + expected: 0,0,1x1 + actual: root/index(0), root/index(2)/index(0), root/index(2)/index(1), root/index(3)/index(0), root/index(3)/index(2), root/index(5) + screen: + 0| Stack demo + 1| ──────────────────────────────────────── + 2| Name: - + 3| left right + 4| ──────────────────────────────────────── + 5| 40 columns + """ + } } @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" - ) + let report = try run( + "app stack\nsize 40x10\ntype \"Rob\"\nkey Backspace\nexpect text 2 \"Name: Ro\"") - #expect(ScenarioRunner.run(scenario, name: "t.scn").passed) + expectNoDifference(report.failures, []) } @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") + let report = try run("app workspace\nsize mobile\nshot a") - #expect(report.failures.first?.verb == "size") + assertInlineSnapshot(of: report, as: .customDump) { + """ + ScenarioReport( + scenario: "t.scn", + passed: false, + failures: [ + [0]: ScenarioFailure( + line: 0, + statement: "app workspace", + verb: "size", + message: "`workspace` fills a 500-row proposal, so it has no natural height; use `size mobileScreen` or an explicit WxH", + expected: nil, + actual: nil, + screen: nil + ) + ], + observations: [] + ) + """ + } } @Test @@ -217,6 +328,7 @@ struct ScenarioCorpusTests { let first = try report(for: file) let second = try report(for: file) + expectNoDifference(first.failures, []) #expect(first.passed) #expect(first.json() == second.json()) } @@ -225,6 +337,54 @@ struct ScenarioCorpusTests { 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]) + assertInlineSnapshot(of: report.human(), as: .lines) { + """ + FAIL failing/mismatch.scn + line 6: expect text 0 "Stack demos" + row 0 text differs + expected: Stack demos + actual: Stack demo + screen: + 0| Stack demo + 1| ──────────────────────────────────────── + 2| Name: - + 3| left right + 4| ──────────────────────────────────────── + 5| 40 columns + line 7: expect text "no such text" + no screen row contains the text + expected: no such text + actual: (not found in 10 rows) + screen: + 0| Stack demo + 1| ──────────────────────────────────────── + 2| Name: - + 3| left right + 4| ──────────────────────────────────────── + 5| 40 columns + line 8: expect node id=root frame 0,0,40x6 + frame of `id=root` differs + expected: 0,0,40x6 + actual: 0,0,40x10 + screen: + 0| Stack demo + 1| ──────────────────────────────────────── + 2| Name: - + 3| left right + 4| ──────────────────────────────────────── + 5| 40 columns + line 10: expect node type=Missing frame 0,0,1x1 + locator `type=Missing` matches no node + expected: 0,0,1x1 + actual: (no node; types: VStack, Text, Divider, HStack, Spacer) + screen: + 0| Stack demo + 1| ──────────────────────────────────────── + 2| Name: - + 3| left right + 4| ──────────────────────────────────────── + 5| 40 columns + """ + } } } 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..3e59819 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -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 and 3 are done (PR #59: the harness seams and the caller-isolated `TerminalSession.draw`; PR #64, stacked on #59: `TesseraRuntime`, `TesseraHarness`, and the Showcase retirement). Slice 4 follows Slice 3 and can branch from `phase-4/03-harness-runtime` until #59 and #64 merge. Decision 9 (i) is recorded below, so nothing in Slices 4-7 waits on the owner; the owner approved the Slice 3 decisions that were marked "(owner to weigh)" on 2026-10-24, so nothing blocks Slice 4. ## Approach @@ -256,11 +256,11 @@ 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 Slice 3 copied the spike instead of moving it (owner, approved 2026-10-24). `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` 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 (`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 `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 (owner, approved 2026-10-24). (this plan, Slice 3 run) +- 2026-10-24 No per-OS `sources:` shims (owner, approved 2026-10-24). 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, approved 2026-10-24). 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; 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) @@ -283,9 +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. 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 (F1) found the `Application` docs and the spec promised a compile error for `extension Notes: Application {}` on a main-actor type that does not appear in every mode. A scratch SwiftPM package with real separate modules (a path dependency on this checkout) settled the rule: it is the compiler mode of the module that writes the extension, not which module declares the type. Plain Swift 6 mode is an error ("crosses into main actor-isolated code and can cause data races") for a type declared `@MainActor` and for one isolated through its module's default isolation, in the same module or another, on Swift 6.3.3 and 6.4. With `.defaultIsolation(MainActor.self)` in the extension's module it builds on both. With `InferIsolatedConformances` it builds on both for a type declared `@MainActor`; for a type isolated only through its own module's default isolation it builds on 6.4 but errors on 6.3.3 when the extension is in another module. The first version of this note and of the docs said the difference was same module versus another module; that generalised from one cross-module `swiftc` run on 6.3.3 whose type was isolated only through default isolation, which is the 6.3 corner above, and the two-module package disproved it. The docs and the spec now state the mode rule and the 6.3 corner, and Slice 4's second fixture runs in plain Swift 6 mode (owner, approved 2026-10-24). - The critic review of PR #64 (F2) noted that after the Showcase left, no test renders a `ViewGraph` through `TerminalSession`, and nothing compiled `import Tessera` from outside the root package. The first stays open until Slice 4's driver snapshot test (its Verify line now says so); the second is guarded by `Examples/Sources/ApplicationConsumer`, a one-file library target that declares a nonisolated and a main-actor `Application` against `import Tessera` only, so the Examples build in CI fails if the re-export goes. -- The critic review of PR #64 (F3) found `CLI.swift` had no caller and no test, so it was removed from the harness. `ScenarioReport.human()`, `ScenarioReport.parseFailure(_:)`, `ShotArtifact`, and the artifacts `ScenarioRunner.execute` returns now have no caller either; they are the report's human form, the parse-failure report, and the `--out` payload that Slice 5's runner and CLI build on, and they stay as moved so the port stays byte-for-byte before any addition. +- The critic review of PR #64 (F3) found `CLI.swift` had no caller and no test, so it was removed from the harness. `ScenarioReport.human()`, `ScenarioReport.parseFailure(_:)`, `ShotArtifact`, and the artifacts `ScenarioRunner.execute` returns now have no caller either; they are the report's human form, the parse-failure report, and the `--out` payload that Slice 5's runner and CLI build on, and they stay as moved so the port stays byte-for-byte before any addition (owner, approved 2026-10-24). ## Outcome diff --git a/docs/spec/phase-5-runtime-and-polish.md b/docs/spec/phase-5-runtime-and-polish.md index f773591..d959891 100644 --- a/docs/spec/phase-5-runtime-and-polish.md +++ b/docs/spec/phase-5-runtime-and-polish.md @@ -50,7 +50,7 @@ public protocol Application: AnyObject { } ``` -The protocol refines nothing but `AnyObject`. It is not `@MainActor`, and it does not refine `SendableMetatype`: that refinement would forbid isolated conformances (SE-0470), which are the compile-time proof this design rests on. The app is a reference type because `body` reads the model and hands out `Binding`s that write back to it. +The protocol is class-only: it inherits from `AnyObject` and from no other protocol. It is not `@MainActor`, and it does not inherit from `SendableMetatype`, because that would forbid isolated conformances (SE-0470), which are the compile-time proof this design rests on. The app is a reference type because `body` reads the model and hands out `Binding`s that write back to it. ### The canonical step diff --git a/spikes/HeadlessRunner/README.md b/spikes/HeadlessRunner/README.md index 5926bf3..515e358 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 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. +The library code has since been copied into the root package's `TesseraHarness` target and `TesseraHarnessTests` (see [developer tooling](../../docs/architecture/developer-tooling.md)), where it is maintained; the `headless` command line was not copied, because the `tessera` command line is written fresh in the harness. This package stays as frozen evidence. ## Hypothesis diff --git a/spikes/README.md b/spikes/README.md index b75b0eb..19d91b6 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 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. +HeadlessRunner's library code has been copied into the root package (`Sources/TesseraHarness` and `Tests/TesseraHarnessTests`; see [developer tooling](../docs/architecture/developer-tooling.md)), where it is maintained; its command line (`headless`, `CLI.swift`) was not copied, because the `tessera` command line is written fresh in the harness. The copy here stays as the record of what the spike proved and is no longer updated. ## Running From 86a1cb565f171c890b0665cf7904f6acda413b0e Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 12:28:23 -0400 Subject: [PATCH 29/55] refactor(harness): apply review conventions to the driver and registry Sort the conformance lists of RegistryError and StillLabel, express the driver, CLI, registry, viewport, and demo tests as inline snapshots and expectNoDifference where they check structured state, and give the DemoSupport tests the snapshot libraries they need. --- Examples/Package.swift | 42 +++ .../DemoSupportTests/CounterDemoTests.swift | 64 +++- .../TesseraHarness/ApplicationRegistry.swift | 2 +- .../RootMeasurementTests.swift | 17 +- Tests/TesseraHarnessTests/DriverTests.swift | 38 +- Tests/TesseraHarnessTests/Fixtures.swift | 2 +- .../TesseraHarnessTests/HarnessCLITests.swift | 327 ++++++++++++++---- Tests/TesseraHarnessTests/RegistryTests.swift | 109 ++++-- Tests/TesseraHarnessTests/ScenarioTests.swift | 92 +++-- 9 files changed, 550 insertions(+), 143 deletions(-) diff --git a/Examples/Package.swift b/Examples/Package.swift index 8b3fd85..8945eb4 100644 --- a/Examples/Package.swift +++ b/Examples/Package.swift @@ -19,6 +19,44 @@ let package = Package( // MARK: - ⤵️ Dependencies +// MARK: CustomDump + +package.dependencies.append( + .package( + url: "https://github.com/pointfreeco/swift-custom-dump", + from: "1.0.0" + ) +) + +let CustomDump: Target.Dependency = .product( + name: "CustomDump", + package: "swift-custom-dump" +) + +// MARK: SnapshotTesting + +package.dependencies.append( + .package( + url: "https://github.com/pointfreeco/swift-snapshot-testing", + exact: "1.19.6" + ) +) + +let InlineSnapshotTesting: Target.Dependency = .product( + name: "InlineSnapshotTesting", + package: "swift-snapshot-testing" +) + +let SnapshotTesting: Target.Dependency = .product( + name: "SnapshotTesting", + package: "swift-snapshot-testing" +) + +let SnapshotTestingCustomDump: Target.Dependency = .product( + name: "SnapshotTestingCustomDump", + package: "swift-snapshot-testing" +) + // MARK: Tessera package.dependencies.append( @@ -97,7 +135,11 @@ package.targets.append(contentsOf: [ .testTarget( name: "DemoSupportTests", dependencies: [ + CustomDump, DemoSupport, + InlineSnapshotTesting, + SnapshotTesting, + SnapshotTestingCustomDump, Tessera, TesseraHarness, ] diff --git a/Examples/Tests/DemoSupportTests/CounterDemoTests.swift b/Examples/Tests/DemoSupportTests/CounterDemoTests.swift index 9938066..92bc605 100644 --- a/Examples/Tests/DemoSupportTests/CounterDemoTests.swift +++ b/Examples/Tests/DemoSupportTests/CounterDemoTests.swift @@ -1,3 +1,7 @@ +import CustomDump +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump import Tessera import Testing @@ -12,9 +16,34 @@ struct CounterDemoTests { 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") + assertInlineSnapshot(of: host.screenRows.joined(separator: "\n"), as: .lines) { + """ + Counter + ──────────────────────────────────────────────────────────────────────────────── + Count 0 + ──────────────────────────────────────────────────────────────────────────────── + + up - down 0 reset q quit + + + + + + + + + + + + + + + + + + + + """ + } } } @@ -22,21 +51,38 @@ struct CounterDemoTests { 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") + assertInlineSnapshot(of: host.screenRows.joined(separator: "\n"), as: .lines) { + """ + Counter + ──────────────────────────────────────── + Count 0 + ──────────────────────────────────────── + + up - down 0 reset q quit + """ + } } } @Test func `plus and minus step the count and zero resets it`() async throws { try await Demos.registry().withHeadlessHost("counter", at: .mobile) { host in + var counts: [String] = [] try await host.send(plus) try await host.send(plus) - #expect(host.screenRows[2].hasSuffix("2")) + counts.append(host.screenRows[2]) try await host.send(.key(Key(code: .character("-")))) - #expect(host.screenRows[2].hasSuffix("1")) + counts.append(host.screenRows[2]) try await host.send(.key(Key(code: .character("0")))) - #expect(host.screenRows[2].hasSuffix("0")) + counts.append(host.screenRows[2]) + assertInlineSnapshot(of: counts, as: .customDump) { + """ + [ + [0]: "Count 2", + [1]: "Count 1", + [2]: "Count 0" + ] + """ + } } } @@ -75,6 +121,6 @@ struct CounterDemoTests { func `the demo registry has no duplicate names`() throws { let names = try Demos.registry().names() - #expect(names == ["counter", "counter-high"]) + expectNoDifference(names, ["counter", "counter-high"]) } } diff --git a/Sources/TesseraHarness/ApplicationRegistry.swift b/Sources/TesseraHarness/ApplicationRegistry.swift index fbcfdac..b2aaf93 100644 --- a/Sources/TesseraHarness/ApplicationRegistry.swift +++ b/Sources/TesseraHarness/ApplicationRegistry.swift @@ -69,7 +69,7 @@ public struct ApplicationRegistry { } /// Why the registry could not answer a question. -package enum RegistryError: Error, Equatable, Sendable, CustomStringConvertible { +package enum RegistryError: CustomStringConvertible, Equatable, Error, Sendable { /// Names registered more than once, each listed once, in sorted order. case duplicate([String]) diff --git a/Tests/TesseraCoreTests/RootMeasurementTests.swift b/Tests/TesseraCoreTests/RootMeasurementTests.swift index 688fd5b..2a99175 100644 --- a/Tests/TesseraCoreTests/RootMeasurementTests.swift +++ b/Tests/TesseraCoreTests/RootMeasurementTests.swift @@ -25,15 +25,16 @@ private let size = TerminalSize(columns: 20, rows: 6) func `a nil height proposal measures the content and a finite one measures the offer`() { let graph = ViewGraph(root: { FillingLeaf() }, size: size) + let measured = [nil, 500, 1_000].map { + graph.measuredRootSize(proposing: ProposedSize(width: 10, height: $0)) + } + #expect( - 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)) + measured == [ + TerminalSize(columns: 10, rows: 3), + TerminalSize(columns: 10, rows: 500), + TerminalSize(columns: 10, rows: 1_000), + ]) } @Test diff --git a/Tests/TesseraHarnessTests/DriverTests.swift b/Tests/TesseraHarnessTests/DriverTests.swift index 8acf4e9..5424ded 100644 --- a/Tests/TesseraHarnessTests/DriverTests.swift +++ b/Tests/TesseraHarnessTests/DriverTests.swift @@ -1,3 +1,7 @@ +import CustomDump +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump import TesseraCore import TesseraRuntime import TesseraTerminal @@ -55,13 +59,13 @@ struct DriverTests { } 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]) + assertInlineSnapshot(of: output.debugDescription, as: .lines) { + #""" + "\u{1B}[?1049h\u{1B}[2J\u{1B}[1;1H\u{1B}[0mCount: 0 \u{1B}[2;1H \u{1B}[0m\u{1B}[?25l\u{1B}[1;8H1\u{1B}[0m\u{1B}[?25l\u{1B}[?25h\u{1B}_Ga=d,d=A\u{1B}\\\u{1B}[?1049l" + """# + } + let closing = Array(await device.events.suffix(2)) + expectNoDifference(closing, [.exitAltScreen, .exitRawMode]) } @Test @@ -130,8 +134,22 @@ struct DriverTests { } #expect(size == TerminalSize(columns: 50, rows: 12)) - #expect(rows.first == "Workspace 50x12") - #expect(rows.count == 12) + assertInlineSnapshot(of: rows.joined(separator: "\n"), as: .lines) { + """ + Workspace 50x12 + ────────────────────────────────────────────────── + Typed: + Key: none + Count: 0 + + + + + + ────────────────────────────────────────────────── + focus=catalog scroll=0 + """ + } } @Test(arguments: [ @@ -165,7 +183,7 @@ struct DriverTests { rows = host.screenRows } - #expect(rows == ["Count: 2", "", ""]) + expectNoDifference(rows, ["Count: 2", "", ""]) } } diff --git a/Tests/TesseraHarnessTests/Fixtures.swift b/Tests/TesseraHarnessTests/Fixtures.swift index 4bb6f70..09d00f5 100644 --- a/Tests/TesseraHarnessTests/Fixtures.swift +++ b/Tests/TesseraHarnessTests/Fixtures.swift @@ -169,7 +169,7 @@ final class GreedyApp: Application { } /// A view whose equal replacement lets reconciliation skip the whole subtree. -private struct StillLabel: View, Equatable { +private struct StillLabel: Equatable, View { var body: some View { Text("Still") } diff --git a/Tests/TesseraHarnessTests/HarnessCLITests.swift b/Tests/TesseraHarnessTests/HarnessCLITests.swift index edf7654..74490f9 100644 --- a/Tests/TesseraHarnessTests/HarnessCLITests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLITests.swift @@ -1,4 +1,8 @@ +import CustomDump import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump import TesseraTerminal import TesseraTerminalCore import Testing @@ -31,23 +35,21 @@ struct HarnessCLITests { } @Test - func `apps prints one JSON object when stdout is not a terminal`() async throws { + func `apps prints one JSON object when stdout is not a terminal`() async { 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(Set(counter.keys) == ["minimum", "name", "viewports"]) - #expect( - apps.compactMap { $0["name"] as? String } - == apps.compactMap { $0["name"] as? String }.sorted()) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + {"apps":[{"minimum":"20x1","name":"counter","viewports":["desktop"]},{"minimum":null,"name":"greedy","viewports":["desktop","mobileScreen"]},{"minimum":null,"name":"main-actor-counter","viewports":["desktop"]},{"minimum":null,"name":"observed-counter","viewports":["desktop"]},{"minimum":null,"name":"stack","viewports":["desktop","mobile"]},{"minimum":null,"name":"workspace","viewports":["desktop","mobileScreen"]}],"command":"apps"} + + """, + stderr: "" + ) + """# + } } @Test @@ -60,28 +62,44 @@ struct HarnessCLITests { 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"]) + expectNoDifference( + Set(apps.map { $0.keys.sorted() }), [["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) 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("{")) + let piped = await run("apps") + + assertInlineSnapshot(of: table, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + NAME MINIMUM VIEWPORTS + counter 20x1 desktop + greedy - desktop, mobileScreen + main-actor-counter - desktop + observed-counter - desktop + stack - desktop, mobile + workspace - desktop, mobileScreen + + """, + stderr: "" + ) + """# + } + expectNoDifference(forced, piped) } @Test func `--pretty forces the table when stdout is not a terminal`() async { let output = await run("apps", "--pretty") + let table = await run("apps", terminal: true) - #expect(output.stdout.hasPrefix("NAME")) + expectNoDifference(output, table) } @Test @@ -96,8 +114,18 @@ struct HarnessCLITests { 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") + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + no applications are registered + + """, + stderr: "" + ) + """# + } } @Test @@ -108,10 +136,28 @@ struct HarnessCLITests { 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`")) + assertInlineSnapshot(of: [apps, launch], as: .customDump) { + #""" + [ + [0]: CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: applications registered more than once: `counter` + + """ + ), + [1]: CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: applications registered more than once: `counter` + + """ + ) + ] + """# + } } @Test @@ -120,30 +166,54 @@ struct HarnessCLITests { { 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")) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown app `ghost`; accepted: counter, greedy, main-actor-counter, observed-counter, stack, workspace + + """ + ) + """# + } } @Test func `launch without a name is a usage error`() async { let output = await run("launch", "--pretty", terminal: true) - #expect(output.exitCode == 2) - #expect(output.stderr.contains("usage: launch NAME")) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: usage: launch NAME + + """ + ) + """# + } } @Test - func `launch refuses to start unless stdout is a terminal`() async throws { + func `launch refuses to start unless stdout is a terminal`() async { 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)?.hasSuffix("standard output is not one") == true) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 3, + stdout: """ + {"command":"launch","exitCode":3,"message":"`launch` runs an application in this terminal, and standard output is not one"} + + """, + stderr: "" + ) + """# + } } @Test @@ -153,66 +223,183 @@ struct HarnessCLITests { { 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" - ) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: `launch` runs an application in this terminal, and standard input is not one + + """ + ) + """# + } } @Test func `launch names both streams when neither is a terminal`() async { let output = await run("launch", "counter", "--pretty") - #expect(output.exitCode == 3) - #expect(output.stderr.contains("standard input and output are not terminals")) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: `launch` runs an application in this terminal, and standard input and output are not terminals + + """ + ) + """# + } } @Test - func `an unknown command has a null command in its JSON failure`() async throws { + func `an unknown command has a null command in its JSON failure`() async { 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) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: """ + {"command":null,"exitCode":2,"message":"unknown command `jump`; accepted: apps, launch, help"} + + """, + stderr: "" + ) + """# + } } @Test func `an unknown command lists the accepted commands`() async { let output = await run("jump", "--pretty") - #expect(output.exitCode == 2) - #expect( - output.stderr == "error: unknown command `jump`; accepted: apps, launch, help\n") + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown command `jump`; accepted: apps, launch, help + + """ + ) + """# + } } @Test func `no command is a usage error`() async { let output = await run("--pretty") - #expect(output.exitCode == 2) - #expect(output.stderr.contains("a command is required")) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: a command is required; accepted: apps, launch, help + + """ + ) + """# + } } @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 outputs = [ + await run("help"), await run("help", "apps"), await run("help", "launch"), + await run("help", "help"), await run("help", "ghost"), + ] + + assertInlineSnapshot(of: outputs, as: .customDump) { + #""" + [ + [0]: CommandOutput( + exitCode: 0, + stdout: """ + 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. + + """, + stderr: "" + ), + [1]: CommandOutput( + exitCode: 0, + stdout: """ + 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. + + """, + stderr: "" + ), + [2]: CommandOutput( + exitCode: 0, + stdout: """ + 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. + + """, + stderr: "" + ), + [3]: CommandOutput( + exitCode: 0, + stdout: """ + usage: help [COMMAND] + + Prints the commands, or one command's usage. + + """, + stderr: "" + ), + [4]: CommandOutput( + exitCode: 2, + stdout: """ + {"command":"help","exitCode":2,"message":"unknown command `ghost`; accepted: apps, launch, help"} + + """, + stderr: "" + ) + ] + """# } - 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) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--json` and `--pretty` contradict each other + + """ + ) + """# + } } } diff --git a/Tests/TesseraHarnessTests/RegistryTests.swift b/Tests/TesseraHarnessTests/RegistryTests.swift index b3ad33c..f3d951a 100644 --- a/Tests/TesseraHarnessTests/RegistryTests.swift +++ b/Tests/TesseraHarnessTests/RegistryTests.swift @@ -1,3 +1,7 @@ +import CustomDump +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump import TesseraTerminalCore import Testing @@ -16,17 +20,40 @@ struct ApplicationRegistryTests { 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]) + assertInlineSnapshot(of: entry, as: .customDump) { + """ + ApplicationRegistry.Entry( + name: "counter", + minimum: TerminalSize( + columns: 20, + rows: 1 + ), + viewports: [ + [0]: .desktop + ], + headless: (Function), + live: (Function) + ) + """ + } } @Test func `entries and names come back sorted by name`() throws { let names = try Fixtures.registry().names() - #expect(names == names.sorted()) - #expect(names.contains("main-actor-counter")) + assertInlineSnapshot(of: names, as: .customDump) { + """ + [ + [0]: "counter", + [1]: "greedy", + [2]: "main-actor-counter", + [3]: "observed-counter", + [4]: "stack", + [5]: "workspace" + ] + """ + } } @Test @@ -35,13 +62,26 @@ struct ApplicationRegistryTests { 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 + assertInlineSnapshot(of: error, as: .customDump) { + """ + RegistryError.unknown( + "ghost", + accepted: [ + [0]: "counter", + [1]: "greedy", + [2]: "main-actor-counter", + [3]: "observed-counter", + [4]: "stack", + [5]: "workspace" + ] + ) + """ + } + assertInlineSnapshot(of: error?.description, as: .lines) { + """ + unknown app `ghost`; accepted: counter, greedy, main-actor-counter, observed-counter, stack, workspace + """ } - #expect(name == "ghost") - #expect(accepted.contains("counter")) - #expect(error?.description.hasPrefix("unknown app `ghost`; accepted: ") == true) } @Test @@ -71,9 +111,26 @@ struct ApplicationRegistryTests { 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) + assertInlineSnapshot(of: report, as: .customDump) { + """ + ScenarioReport( + scenario: "t.scn", + passed: false, + failures: [ + [0]: ScenarioFailure( + line: 0, + statement: "app workspace", + verb: "app", + message: "applications registered more than once: `counter`, `stack`", + expected: nil, + actual: nil, + screen: nil + ) + ], + observations: [] + ) + """ + } } @Test @@ -88,12 +145,19 @@ struct ApplicationRegistryTests { 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) + let viewports: [Viewport] = [ + .desktop, .mobileScreen, .custom(TerminalSize(columns: 30, rows: 9)), .mobile, + ] + + expectNoDifference( + viewports.map(\.fixedSize), + [ + TerminalSize(columns: 80, rows: 24), + TerminalSize(columns: 40, rows: 16), + TerminalSize(columns: 30, rows: 9), + nil, + ] + ) } @Test @@ -102,8 +166,7 @@ struct ViewportTests { .desktop, .mobile, .mobileScreen, .custom(TerminalSize(columns: 30, rows: 9)), ] - #expect(viewports.count == 4) - #expect( - viewports.map(\.name).sorted() == ["30x9", "desktop", "mobile", "mobileScreen"]) + expectNoDifference( + viewports.map(\.name).sorted(), ["30x9", "desktop", "mobile", "mobileScreen"]) } } diff --git a/Tests/TesseraHarnessTests/ScenarioTests.swift b/Tests/TesseraHarnessTests/ScenarioTests.swift index 43c65ec..5c42f35 100644 --- a/Tests/TesseraHarnessTests/ScenarioTests.swift +++ b/Tests/TesseraHarnessTests/ScenarioTests.swift @@ -57,21 +57,20 @@ struct ScenarioParserTests { @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") + assertInlineSnapshot(of: parseFailure(of: "size desktop\nkey Tab"), as: .lines) { + """ + t.scn:1: error: a scenario names its app with an `app NAME` header; accepted: stack, workspace, greedy + """ } - - #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") + assertInlineSnapshot(of: parseFailure(of: "app missing"), as: .lines) { + """ + t.scn:1: error: unknown app `missing`; accepted: stack, workspace, greedy + """ } - - #expect(error?.message.contains("unknown app `missing`") == true) } @Test @@ -302,7 +301,7 @@ struct ScenarioRunnerTests { let report = try await run( "app stack\nsize mobile\nexpect node id=root frame 0,0,40x6") - #expect(report.passed) + expectNoDifference(report.failures, []) } @Test @@ -311,10 +310,26 @@ struct ScenarioRunnerTests { { 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")) + assertInlineSnapshot(of: report, as: .customDump) { + """ + ScenarioReport( + scenario: "t.scn", + passed: false, + failures: [ + [0]: ScenarioFailure( + line: 0, + statement: "app greedy", + verb: "size", + message: "`greedy` has no natural height: it measured 500 rows when offered 500 and 1000 when offered 1000. Use `size mobileScreen` (40x16) or an explicit WxH", + expected: nil, + actual: nil, + screen: nil + ) + ], + observations: [] + ) + """ + } } @Test @@ -350,7 +365,7 @@ struct ScenarioRunnerTests { let report = try await run( "app greedy\nsize mobileScreen\nexpect node id=root frame 0,0,40x16") - #expect(report.passed) + expectNoDifference(report.failures, []) } @Test @@ -360,8 +375,26 @@ struct ScenarioRunnerTests { 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) + assertInlineSnapshot(of: report, as: .customDump) { + """ + ScenarioReport( + scenario: "t.scn", + passed: false, + failures: [ + [0]: ScenarioFailure( + line: 0, + statement: "app ghost", + verb: "app", + message: "unknown app `ghost`; accepted: counter, greedy, main-actor-counter, observed-counter, stack, workspace", + expected: nil, + actual: nil, + screen: nil + ) + ], + observations: [] + ) + """ + } } @Test @@ -370,10 +403,27 @@ struct ScenarioRunnerTests { 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) + assertInlineSnapshot(of: [stack, workspace, greedy], as: .customDump) { + """ + [ + [0]: NaturalHeight( + rows: 6, + shortRows: 6, + tallRows: 6 + ), + [1]: NaturalHeight( + rows: 34, + shortRows: 500, + tallRows: 1000 + ), + [2]: NaturalHeight( + rows: 2, + shortRows: 500, + tallRows: 1000 + ) + ] + """ + } } } From 52d4bbc8ec6b36796c1bbdc39c3aa3ea0a26832d Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 12:40:26 -0400 Subject: [PATCH 30/55] refactor(harness): apply review conventions to the scenario runner Express the scenario parser, player, runner, CLI, and corpus tests as inline snapshots and expectNoDifference where they check structured state, redact the scratch directory from file-path output so those snapshots are the same in every run, and cite the architecture page instead of a decision number in the CI comment. --- .github/workflows/ci.yml | 7 +- .../ScenarioCorpusTests.swift | 6 +- .../HarnessCLIScenarioTests.swift | 475 +++++++--- .../ScenarioRunnerTests.swift | 669 +++++++++++--- Tests/TesseraHarnessTests/ScenarioTests.swift | 831 +++++++++++++----- .../ScratchDirectory.swift | 23 + .../TerminalAnswererTests.swift | 16 +- 7 files changed, 1578 insertions(+), 449 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed870c8..3844fe1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,9 +228,10 @@ jobs: swift test --only-use-versions-from-resolved-file --no-parallel --package-path Examples - # The behaviour corpus (Decision 7): every leg, Windows included, runs the scenarios in - # Examples/Scenarios against their committed goldens, which are checked out with LF - # (.gitattributes). A drifted golden or a failed expectation fails the leg. + # The behaviour corpus (docs/architecture/developer-tooling.md): every leg, Windows + # included, runs the scenarios in Examples/Scenarios against their committed goldens, + # which are checked out with LF (.gitattributes). A drifted golden or a failed + # expectation fails the leg. - name: Run the scenario corpus if: needs.ci-gate.outputs.vouch-only != 'true' run: diff --git a/Examples/Tests/DemoSupportTests/ScenarioCorpusTests.swift b/Examples/Tests/DemoSupportTests/ScenarioCorpusTests.swift index 3741b1a..4c34690 100644 --- a/Examples/Tests/DemoSupportTests/ScenarioCorpusTests.swift +++ b/Examples/Tests/DemoSupportTests/ScenarioCorpusTests.swift @@ -1,3 +1,4 @@ +import CustomDump import DemoSupport import TesseraHarness import Testing @@ -18,8 +19,7 @@ struct ScenarioCorpusTests { 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"))") + expectNoDifference(reports.filter { !$0.passed }, []) #expect(reports.count >= 5) } @@ -30,7 +30,7 @@ struct ScenarioCorpusTests { let first = await runner.run(Self.corpus).map(\.json) let second = await runner.run(Self.corpus).map(\.json) - #expect(first == second) + expectNoDifference(first, second) #expect(!first.isEmpty) } } diff --git a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift index a682b6b..eca8b6d 100644 --- a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift @@ -1,4 +1,8 @@ +import CustomDump import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump import TesseraTerminal import TesseraTerminalCore import Testing @@ -46,17 +50,18 @@ struct HarnessCLIScenarioTests { 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 }) + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + {"command":"run","exitCode":0,"scenarios":[{"failures":[],"observations":[],"passed":true,"scenario":"/a.scn"},{"failures":[],"observations":[],"passed":true,"scenario":"/b.scn"}]} + + """, + stderr: "" + ) + """# + } } @Test @@ -67,12 +72,31 @@ struct HarnessCLIScenarioTests { 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) + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 1, + stdout: """ + PASS /a.scn + FAIL /b.scn + line 3: expect text 0 "Stack demos" + row 0 text differs + expected: Stack demos + actual: Stack demo + screen: + 0| Stack demo + 1| ──────────────────────────────────────── + 2| Name: - + 3| left right + 4| ──────────────────────────────────────── + 5| 40 columns + 2 scenarios, 1 passed, 1 failed + + """, + stderr: "" + ) + """# + } } @Test @@ -82,7 +106,19 @@ struct HarnessCLIScenarioTests { let output = await run("run", scratch.path, "--pretty") - #expect(output.stdout.hasSuffix("1 scenario, 1 passed, 0 failed\n")) + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + PASS /a.scn + 1 scenario, 1 passed, 0 failed + + """, + stderr: "" + ) + """# + } } @Test @@ -94,14 +130,18 @@ struct HarnessCLIScenarioTests { 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") + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 1, + stdout: """ + {"command":"run","exitCode":1,"scenarios":[{"failures":[{"actual":"Stack demo","expected":"Stack demos","line":3,"message":"row 0 text differs","screen":["Stack demo","────────────────────────────────────────","Name: -","left right","────────────────────────────────────────","40 columns"],"statement":"expect text 0 \"Stack demos\"","verb":"expect text"}],"observations":[],"passed":false,"scenario":"/b.scn"}]} + + """, + stderr: "" + ) + """# + } } @Test @@ -111,13 +151,18 @@ struct HarnessCLIScenarioTests { 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") + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 2, + stdout: """ + {"command":"run","exitCode":2,"scenarios":[{"failures":[{"line":2,"message":"unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node","statement":"blink","verb":"parse"}],"observations":[],"passed":false,"scenario":"/bad.scn"}]} + + """, + stderr: "" + ) + """# + } } @Test @@ -126,10 +171,18 @@ struct HarnessCLIScenarioTests { 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") + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 2, + stdout: """ + {"command":"run","exitCode":2,"scenarios":[{"failures":[{"line":0,"message":"no such file or directory","statement":"","verb":"usage"}],"observations":[],"passed":false,"scenario":"/missing.scn"}]} + + """, + stderr: "" + ) + """# + } } @Test @@ -162,18 +215,36 @@ struct HarnessCLIScenarioTests { let output = await run("run", scratch.path, registry: registry) - #expect(output.exitCode == 3) + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 3, + stdout: """ + {"command":"run","exitCode":3,"scenarios":[{"failures":[{"line":0,"message":"applications registered more than once: `stack`","statement":"","verb":"harness"}],"observations":[],"passed":false,"scenario":"/a.scn"}]} + + """, + stderr: "" + ) + """# + } } @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) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: """ + {"command":"run","exitCode":2,"message":"usage: run PATH... [--record] [--repeat N] [--timing]"} + + """, + stderr: "" + ) + """# + } } @Test @@ -184,16 +255,52 @@ struct HarnessCLIScenarioTests { 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")) + assertInlineSnapshot(of: [unknown, noValue, zero, word, twice], as: .customDump) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown flag `--frobnicate`; accepted: --json, --pretty, --record, --repeat, --timing + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--repeat` needs a value + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--repeat` needs a whole number of at least 1, got `0` + + """ + ), + [3]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--repeat` needs a whole number of at least 1, got `many` + + """ + ), + [4]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--repeat` is given twice + + """ + ) + ] + """# + } } @Test @@ -202,10 +309,18 @@ struct HarnessCLIScenarioTests { { let output = await run("run", "--repeat", "x.scn", "--pretty") - #expect(output.exitCode == 2) - #expect( - output.stderr - == "error: `--repeat` needs a whole number of at least 1, got `x.scn`\n") + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--repeat` needs a whole number of at least 1, got `x.scn` + + """ + ) + """# + } } @Test @@ -243,32 +358,41 @@ struct HarnessCLIScenarioTests { 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 - - """) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + Stack demo + ──────────────────────────────────────── + Name: - + left right + ──────────────────────────────────────── + 40 columns + + """, + stderr: "" + ) + """# + } } @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) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + {"app":"stack","command":"shot","exitCode":0,"rows":["Stack demo","────────────────────────────────────────","Name: -","left right","────────────────────────────────────────","40 columns"],"size":"40x6"} + + """, + stderr: "" + ) + """# + } } @Test @@ -284,9 +408,30 @@ struct HarnessCLIScenarioTests { 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")) + assertInlineSnapshot(of: text, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + root TupleView frame=0,0,40x8 + root/index(0) VStack frame=0,0,40x8 + root/index(0)/index(0) Text frame=0,0,10x1 + root/index(0)/index(1) Divider frame=0,1,40x1 + root/index(0)/index(2) HStack frame=0,2,7x1 + root/index(0)/index(2)/index(0) Text frame=0,2,5x1 + root/index(0)/index(2)/index(1) Text frame=6,2,1x1 + root/index(0)/index(3) HStack frame=0,3,40x1 + root/index(0)/index(3)/index(0) Text frame=0,3,4x1 + root/index(0)/index(3)/index(1) Spacer frame=4,3,31x0 clip=4,3,0x0 + root/index(0)/index(3)/index(2) Text frame=35,3,5x1 + root/index(0)/index(4) Divider frame=0,4,40x1 + root/index(0)/index(5) Text frame=0,5,10x1 + + """, + stderr: "" + ) + """# + } let object = try object(json) #expect(Set(object.keys) == ["app", "command", "exitCode", "lines", "size"]) #expect( @@ -311,15 +456,62 @@ struct HarnessCLIScenarioTests { 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) + assertInlineSnapshot( + of: [unknown, badSize, none, extra, greedy, duplicate], as: .customDump + ) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown app `ghost`; accepted: counter, event-log, greedy, main-actor-counter, observed-counter, stack, workspace + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: bad size `huge`; expected WxH, desktop, mobile, or mobileScreen + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: usage: shot NAME [SIZE] + + """ + ), + [3]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: usage: shot NAME [SIZE] + + """ + ), + [4]: CommandOutput( + exitCode: 2, + stdout: """ + {"command":"shot","exitCode":2,"message":"`greedy` has no natural height: it measured 500 rows when offered 500 and 1000 when offered 1000. Use `size mobileScreen` (40x16) or an explicit WxH"} + + """, + stderr: "" + ), + [5]: CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: applications registered more than once: `stack` + + """ + ) + ] + """# + } } // MARK: check and replay @@ -330,12 +522,27 @@ struct HarnessCLIScenarioTests { 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) + assertInlineSnapshot(of: [check, replay], as: .customDump) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: """ + {"command":"check","exitCode":2,"message":"`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"} + + """, + stderr: "" + ), + [1]: CommandOutput( + exitCode: 2, + stdout: """ + {"command":"replay","exitCode":2,"message":"`replay` is not yet available: it reruns a recorded trace, and the harness records no traces yet"} + + """, + stderr: "" + ) + ] + """# } } @@ -346,10 +553,44 @@ struct HarnessCLIScenarioTests { 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") + assertInlineSnapshot(of: [noApp, badSize, noFile, noName], as: .customDump) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: usage: check NAME [SIZE] + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: bad size `huge`; expected WxH, desktop, mobile, or mobileScreen + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: usage: replay FILE --app NAME + + """ + ), + [3]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: usage: replay FILE --app NAME + + """ + ) + ] + """# + } } @Test @@ -361,12 +602,36 @@ struct HarnessCLIScenarioTests { let replay = await run("replay", "run.trace", "--app", "ghost", "--pretty") let duplicate = await run("check", "stack", "--pretty", registry: duplicated) - #expect(check.exitCode == 2) - #expect(check.stderr.hasPrefix("error: unknown app `ghost`; accepted: ")) - #expect(replay.exitCode == 2) - #expect(replay.stderr.hasPrefix("error: unknown app `ghost`; accepted: ")) - #expect(duplicate.exitCode == 3) - #expect(duplicate.stderr.contains("registered more than once")) + assertInlineSnapshot(of: [check, replay, duplicate], as: .customDump) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown app `ghost`; accepted: counter, event-log, greedy, main-actor-counter, observed-counter, stack, workspace + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown app `ghost`; accepted: counter, event-log, greedy, main-actor-counter, observed-counter, stack, workspace + + """ + ), + [2]: CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: applications registered more than once: `stack` + + """ + ) + ] + """# + } } // MARK: help diff --git a/Tests/TesseraHarnessTests/ScenarioRunnerTests.swift b/Tests/TesseraHarnessTests/ScenarioRunnerTests.swift index 83c261b..7f4dd6e 100644 --- a/Tests/TesseraHarnessTests/ScenarioRunnerTests.swift +++ b/Tests/TesseraHarnessTests/ScenarioRunnerTests.swift @@ -1,4 +1,8 @@ +import CustomDump import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump import TesseraCore import TesseraRuntime import TesseraTerminalInput @@ -67,11 +71,13 @@ struct ScenarioRunnerFileTests { let reports = await runner().run(scratch.path) - #expect( - reports.map(\.scenario) == [ + expectNoDifference( + reports.map(\.scenario), + [ scratch.appending("a.scn"), scratch.appending("b.scn"), scratch.appending("nested/a.scn"), scratch.appending("nested/deep/c.scn"), - ]) + ] + ) } @Test @@ -83,7 +89,7 @@ struct ScenarioRunnerFileTests { let reports = await runner().run(scratch.path + "/") - #expect(reports.map(\.scenario) == [scratch.appending("a.scn")]) + expectNoDifference(reports.map(\.scenario), [scratch.appending("a.scn")]) } @Test @@ -98,12 +104,70 @@ struct ScenarioRunnerFileTests { 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)")) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/missing.scn", + failures: [ + [0]: ScenarioFailure( + line: 0, + statement: "", + verb: "usage", + message: "no such file or directory", + expected: nil, + actual: nil, + screen: nil + ) + ], + observations: [], + recorded: [], + seconds: [] + ), + [1]: ScenarioReport( + scenario: "/plain.txt", + failures: [ + [0]: ScenarioFailure( + line: 0, + statement: "", + verb: "usage", + message: "scenario files end in `.scn`", + expected: nil, + actual: nil, + screen: nil + ) + ], + observations: [], + recorded: [], + seconds: [] + ), + [2]: ScenarioReport( + scenario: "/empty", + failures: [ + [0]: ScenarioFailure( + line: 0, + statement: "", + verb: "usage", + message: "no scenario files (*.scn) under this directory", + expected: nil, + actual: nil, + screen: nil + ) + ], + observations: [], + recorded: [], + seconds: [] + ), + [3]: ScenarioReport( + scenario: "/ok.scn", + failures: [], + observations: [], + recorded: [], + seconds: [] + ) + ] + """ + } } @Test @@ -122,11 +186,76 @@ struct ScenarioRunnerFileTests { 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", - ]) + assertInlineSnapshot(of: reports[0].failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 6, + statement: #"expect text 0 "Stack demos""#, + verb: "expect text", + message: "row 0 text differs", + expected: "Stack demos", + actual: "Stack demo", + screen: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: -", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ] + ), + [1]: ScenarioFailure( + line: 7, + statement: #"expect text "no such text""#, + verb: "expect text", + message: "no screen row contains the text", + expected: "no such text", + actual: "(not found in 10 rows)", + screen: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: -", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ] + ), + [2]: ScenarioFailure( + line: 8, + statement: "expect node id=root frame 0,0,40x6", + verb: "expect node", + message: "frame of `id=root` differs", + expected: "0,0,40x6", + actual: "0,0,40x10", + screen: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: -", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ] + ), + [3]: ScenarioFailure( + line: 10, + statement: "expect node type=Missing frame 0,0,1x1", + verb: "expect node", + message: "locator `type=Missing` matches no node", + expected: "0,0,1x1", + actual: "(no node; types: TupleView, VStack, Text, Divider, HStack, Spacer)", + screen: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: -", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ] + ) + ] + """ + } } // MARK: Parse errors @@ -136,25 +265,52 @@ struct ScenarioRunnerFileTests { async throws { let scratch = try ScratchDirectory() - let bad = try scratch.write("a-bad.scn", "app stack\nsize 40x10\nblink 3\n") + 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""#)) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/a-bad.scn", + failures: [ + [0]: ScenarioFailure( + line: 3, + statement: "blink 3", + verb: "parse", + message: "unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node", + expected: nil, + actual: nil, + screen: nil + ) + ], + observations: [], + recorded: [], + seconds: [] + ), + [1]: ScenarioReport( + scenario: "/b-good.scn", + failures: [], + observations: [], + recorded: [], + seconds: [] + ) + ] + """ + } + assertInlineSnapshot(of: scratch.redacted(reports[0].description), as: .lines) { + """ + FAIL /a-bad.scn + /a-bad.scn:3: error: unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + """ + } + assertInlineSnapshot(of: scratch.redacted(reports[0].json), as: .lines) { + """ + {"failures":[{"line":3,"message":"unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node","statement":"blink 3","verb":"parse"}],"observations":[],"passed":false,"scenario":"/a-bad.scn"} + """ + } } @Test @@ -180,10 +336,29 @@ struct ScenarioRunnerFileTests { 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) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/a.scn", + failures: [ + [0]: ScenarioFailure( + line: 0, + statement: "", + verb: "harness", + message: "applications registered more than once: `stack`", + expected: nil, + actual: nil, + screen: nil + ) + ], + observations: [], + recorded: [], + seconds: [] + ) + ] + """ + } } // MARK: Goldens @@ -197,12 +372,43 @@ struct ScenarioRunnerFileTests { 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) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [ + [0]: ScenarioFailure( + line: 5, + statement: "shot after-typing", + verb: "shot", + message: "shot `after-typing` has no golden at `/stack/after-typing.txt`; run with --record to create it, and review the file", + expected: nil, + actual: nil, + screen: nil + ) + ], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ] + ) + ], + recorded: [], + seconds: [] + ) + ] + """ + } #expect(scratch.read("stack/after-typing.txt") == nil) } @@ -215,24 +421,76 @@ struct ScenarioRunnerFileTests { 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 - - """) + assertInlineSnapshot(of: scratch.dump(recorded), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ] + ) + ], + recorded: [ + [0]: GoldenChange( + path: "/stack/after-typing.txt", + change: .created + ) + ], + seconds: [] + ) + ] + """ + } + assertInlineSnapshot(of: scratch.dump(checked), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ] + ) + ], + recorded: [], + seconds: [] + ) + ] + """ + } + assertInlineSnapshot(of: scratch.read("stack/after-typing.txt"), as: .lines) { + """ + Stack demo + ──────────────────────────────────────── + Name: Rob + left right + ──────────────────────────────────────── + 40 columns + + """ + } } @Test @@ -249,15 +507,50 @@ struct ScenarioRunnerFileTests { 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) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + #""" + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [ + [0]: ScenarioFailure( + line: 5, + statement: "shot after-typing", + verb: "shot", + message: "shot `after-typing` differs from `/stack/after-typing.txt` at row 2: the golden has 6 rows and the screen has 6. If the new rendering is intended, rerun with --record and review the golden\'s diff", + expected: "Name: Bob", + actual: "Name: Rob", + screen: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ] + ) + ], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ] + ) + ], + recorded: [], + seconds: [] + ) + ] + """# + } #expect(scratch.read("stack/after-typing.txt")?.contains("Name: Bob") == true) } @@ -269,13 +562,44 @@ struct ScenarioRunnerFileTests { 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)")) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ] + ) + ], + recorded: [ + [0]: GoldenChange( + path: "/stack/after-typing.txt", + change: .updated + ) + ], + seconds: [] + ) + ] + """ + } + assertInlineSnapshot(of: scratch.redacted(reports[0].description), as: .lines) { + """ + PASS /stack.scn + recorded /stack/after-typing.txt (updated) + """ + } #expect(scratch.read("stack/after-typing.txt")?.hasPrefix("Stack demo\n") == true) } @@ -305,9 +629,43 @@ struct ScenarioRunnerFileTests { 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")) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [ + [0]: ScenarioFailure( + line: 5, + statement: "shot after-typing", + verb: "shot", + message: "golden `/stack/after-typing.txt` has CRLF line endings; goldens are LF files, so check them out with LF (`*.txt text eol=lf` in .gitattributes)", + expected: nil, + actual: nil, + screen: nil + ) + ], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ] + ) + ], + recorded: [], + seconds: [] + ) + ] + """ + } } @Test @@ -326,12 +684,43 @@ struct ScenarioRunnerFileTests { let reports = await runner().run(scratch.path) let recorded = await runner(record: true).run(scratch.path) - let failure = try #require(reports.first?.failures.first) - #expect(failure.verb == "shot") - #expect(failure.message.contains("CRLF line endings")) - #expect(failure.expected == nil) - #expect(failure.actual == nil) - #expect(failure.screen == nil) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [ + [0]: ScenarioFailure( + line: 5, + statement: "shot after-typing", + verb: "shot", + message: "golden `/stack/after-typing.txt` has CRLF line endings; goldens are LF files, so check them out with LF (`*.txt text eol=lf` in .gitattributes)", + expected: nil, + actual: nil, + screen: nil + ) + ], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ] + ) + ], + recorded: [], + seconds: [] + ) + ] + """ + } #expect(recorded.map(\.passed) == [true]) #expect(scratch.read("stack/after-typing.txt") == lf) } @@ -364,7 +753,7 @@ struct ScenarioRunnerFileTests { let first = await runner().run(scratch.path).map(\.json) let second = await runner().run(scratch.path).map(\.json) - #expect(first == second) + expectNoDifference(first, second) #expect(first.count == 2) } @@ -394,11 +783,30 @@ struct ScenarioRunnerFileTests { 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")) + assertInlineSnapshot(of: reports.first?.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 0, + statement: "--repeat 3", + verb: "repeat", + message: "run 2 of 3 differs from run 1: it has 1 failures where run 1 has 0", + expected: nil, + actual: nil, + screen: nil + ), + [1]: ScenarioFailure( + line: 0, + statement: "--repeat 3", + verb: "repeat", + message: "run 3 of 3 differs from run 1: it has 1 failures where run 1 has 0", + expected: nil, + actual: nil, + screen: nil + ) + ] + """ + } } @Test @@ -453,40 +861,44 @@ struct ScenarioReportShapeTests { 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"}"#) + assertInlineSnapshot(of: json, as: .lines) { + #""" + {"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") + assertInlineSnapshot(of: report.json, as: .lines) { + """ + {"failures":[],"observations":[],"passed":true,"scenario":"ok.scn"} + """ + } + assertInlineSnapshot(of: report.description, as: .lines) { + """ + 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 - """) + assertInlineSnapshot(of: failing.description, as: .lines) { + """ + 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 @@ -496,9 +908,32 @@ struct ScenarioReportShapeTests { 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") + assertInlineSnapshot(of: parse.description, as: .lines) { + """ + FAIL p.scn + p.scn:3: error: unknown + """ + } + assertInlineSnapshot(of: usage.description, as: .lines) { + """ + FAIL dir + dir: error: no such file + """ + } + assertInlineSnapshot(of: parse.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 3, + statement: "blink", + verb: "parse", + message: "unknown", + expected: nil, + actual: nil, + screen: nil + ) + ] + """ + } } } diff --git a/Tests/TesseraHarnessTests/ScenarioTests.swift b/Tests/TesseraHarnessTests/ScenarioTests.swift index 47e45ab..50a87ba 100644 --- a/Tests/TesseraHarnessTests/ScenarioTests.swift +++ b/Tests/TesseraHarnessTests/ScenarioTests.swift @@ -1,4 +1,8 @@ +import CustomDump import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump import TesseraCore import TesseraTerminalInput import Testing @@ -9,6 +13,29 @@ private func parse(_ source: String, file: String = "t.scn") throws -> Scenario try ScenarioParser.parse(source, file: file, knownApps: Fixtures.names) } +/// The parse error `source` raises. +private func parseError(of source: String) -> ScenarioParseError? { + do { + _ = try parse(source) + Issue.record("`\(source)` parsed without an error") + } catch let error as ScenarioParseError { + return error + } catch { + Issue.record(error, "`\(source)` raised a different error") + } + return nil +} + +/// The text of the parse error `source` raises, in the `FILE:LINE: error: message` form. +private func parseFailure(of source: String) -> String { + parseError(of: source)?.description ?? "" +} + +/// The failure text of each source, one per line, for tests that check a family of inputs. +private func parseFailures(of sources: [String]) -> String { + sources.map(parseFailure(of:)).joined(separator: "\n") +} + struct ScenarioParserTests { @Test func `header statements bind before the first body statement`() throws { @@ -22,13 +49,33 @@ struct ScenarioParserTests { """ ) - #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 == 5) + assertInlineSnapshot(of: scenario, as: .customDump) { + """ + Scenario( + app: "stack", + appLine: SourceLine( + number: 2, + text: "app stack" + ), + viewport: .mobile, + sizeLine: SourceLine( + number: 3, + text: "size mobile" + ), + terminal: TerminalProfile( + colors: .ansi16, + theme: .light + ), + lines: [ + [0]: ScenarioLine( + number: 5, + source: #"type "hi there" # trailing comment"#, + statement: .type("hi there") + ) + ] + ) + """ + } } @Test @@ -37,78 +84,92 @@ struct ScenarioParserTests { { let scenario = try parse("app stack") - #expect(scenario.terminal == TerminalProfile(colors: .none, theme: nil)) - #expect(scenario.viewport == .desktop) - #expect(scenario.sizeLine == nil) + assertInlineSnapshot(of: scenario, as: .customDump) { + """ + Scenario( + app: "stack", + appLine: SourceLine( + number: 1, + text: "app stack" + ), + viewport: .desktop, + sizeLine: nil, + terminal: TerminalProfile( + colors: .none, + theme: nil + ), + lines: [] + ) + """ + } } @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") + assertInlineSnapshot(of: parseFailure(of: "size desktop\nkey Tab"), as: .lines) { + """ + t.scn:1: error: a scenario names its app with an `app NAME` header; accepted: stack, workspace, greedy, event-log, counter + """ } - - #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") + assertInlineSnapshot(of: parseFailure(of: "app missing"), as: .lines) { + """ + t.scn:1: error: unknown app `missing`; accepted: stack, workspace, greedy, event-log, counter + """ } - - #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 parse("key Tab\nsize 10x10") + assertInlineSnapshot(of: parseError(of: "key Tab\nsize 10x10"), as: .customDump) { + """ + ScenarioParseError( + file: "t.scn", + line: 2, + source: "size 10x10", + message: "`size` is a header statement; use `resize WxH` after the first body statement" + ) + """ } - - #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)") - } + let failures = parseFailures( + of: ["app stack", "term colors=16"].map { "app stack\nkey Tab\n\($0)" }) - #expect(error?.line == 3, "\(header)") - #expect(error?.message.contains("must precede the first body statement") == true) + assertInlineSnapshot(of: failures, as: .lines) { + """ + t.scn:3: error: `app` is a header statement and must precede the first body statement + t.scn:3: error: `term` is a header statement and must precede the first body statement + """ } } @Test func `unknown statements list the accepted vocabulary`() { - let error = #expect(throws: ScenarioParseError.self) { - try parse("app stack\njump 3") + assertInlineSnapshot(of: parseFailure(of: "app stack\njump 3"), as: .lines) { + """ + t.scn:2: error: unknown statement `jump`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + """ } - - #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 `unknown expectations list the accepted kinds`() { - let error = #expect(throws: ScenarioParseError.self) { - try parse("app stack\nexpect colour red") + assertInlineSnapshot(of: parseFailure(of: "app stack\nexpect colour red"), as: .lines) + { + """ + t.scn:2: error: unknown expectation `colour`; accepted: text, screen, node + """ } - - #expect( - error?.message == "unknown expectation `colour`; accepted: text, screen, node") } @Test - func `every statement form the grammar defers is refused by name`() throws { + func `every statement form the grammar defers is refused by name`() { let samples: [String: String] = [ "wait": "wait", "settle": "settle", @@ -130,17 +191,34 @@ struct ScenarioParserTests { "term keyboard=": "term keyboard=kitty", "term theme=probe": "term theme=probe", ] - #expect(Set(samples.keys) == Set(ScenarioGrammar.notYetExpressible.map(\.form))) + expectNoDifference( + samples.keys.sorted(), ScenarioGrammar.notYetExpressible.map(\.form).sorted()) - for (form, sample) in samples { - let error = #expect(throws: ScenarioParseError.self) { - try parse("app stack\n" + sample) - } + let failures = parseFailures( + of: samples.sorted { $0.key < $1.key }.map { "app stack\n" + $0.value }) - #expect(error?.line == 2, "\(form)") - #expect( - error?.message.hasPrefix("`\(form)` is not yet expressible: it needs ") == true, - "\(form): \(error?.message ?? "no error")") + assertInlineSnapshot(of: failures, as: .lines) { + """ + t.scn:2: error: `check` is not yet expressible: it needs the invariant rules, which read the projection. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `dump` is not yet expressible: it needs the projection. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `expect cell` is not yet expressible: it needs style and role assertions on cells. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `expect cost` is not yet expressible: it needs the per-step cost counters. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `expect focused` is not yet expressible: it needs focus tracking. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `expect node SEL clipped` is not yet expressible: it needs the projection. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `expect node SEL count N` is not yet expressible: it needs the projection. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `expect node SEL visible` is not yet expressible: it needs the projection. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `expect wireframe` is not yet expressible: it needs catalog conformance. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `focus` is not yet expressible: it needs focus tracking and role and label locators. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `hit` is not yet expressible: it needs pointer input and hit testing. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `mouse` is not yet expressible: it needs pointer input and hit testing. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `settle` is not yet expressible: it needs the schedulable clock. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `shot NAME SEL` is not yet expressible: it needs selectors over the projection. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `term glyphs=` is not yet expressible: it needs ASCII glyph fallbacks in the views. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `term keyboard=` is not yet expressible: it needs key routing that depends on the protocol. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `term theme=probe` is not yet expressible: it needs semantic roles. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `tree SEL` is not yet expressible: it needs selectors over the projection. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + t.scn:2: error: `wait` is not yet expressible: it needs the schedulable clock. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node + """ } } @@ -164,29 +242,32 @@ struct ScenarioParserTests { String(line.dropFirst(3).prefix { $0 != "`" }) } - #expect(forms.sorted() == ScenarioGrammar.notYetExpressible.map(\.form).sorted()) + expectNoDifference( + forms.sorted(), ScenarioGrammar.notYetExpressible.map(\.form).sorted()) } @Test func `unknown key names list the accepted set`() { - let error = #expect(throws: ScenarioParseError.self) { - try parse("app stack\nkey Hyperspace") + assertInlineSnapshot(of: parseFailure(of: "app stack\nkey Hyperspace"), as: .lines) { + """ + t.scn:2: error: unknown key `Hyperspace`; accepted: ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Backspace, Delete, End, Enter, Escape, Home, PageDown, PageUp, Space, Tab, F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, a single character; modifiers: Alt, Control, Ctrl, Meta, Shift, Super joined with `+` + """ } - - #expect(error?.message.contains("ArrowLeft") == true) } @Test func `chords and function keys parse into key events`() throws { 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 } - } - #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)) + expectNoDifference( + scenario.lines.map(\.statement), + [ + .key(Key(code: .tab, modifiers: .shift)), + .key(Key(code: .character("c"), modifiers: .control)), + .key(Key(code: .function(5))), + .key(Key(code: .character("+"), modifiers: .control)), + ] + ) } @Test @@ -195,41 +276,46 @@ struct ScenarioParserTests { "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) + let expected = Statement.expectNode( + Locator(id: "root"), + FrameExpectation(column: 1, row: 2, columns: 3, rows: 4) + ) + + expectNoDifference(scenario.lines.map(\.statement), [expected, expected]) } @Test func `unterminated quotes fail with the line number`() { - let error = #expect(throws: ScenarioParseError.self) { - try parse("app stack\n\ntype \"oops") + assertInlineSnapshot(of: parseFailure(of: "app stack\n\ntype \"oops"), as: .lines) { + """ + t.scn:3: error: unterminated double quote + """ } - - #expect(error?.line == 3) } @Test func `duplicate shot names are rejected, naming the first line`() { - let error = #expect(throws: ScenarioParseError.self) { - try parse("app stack\nshot a\nshot a") + assertInlineSnapshot(of: parseFailure(of: "app stack\nshot a\nshot a"), as: .lines) { + """ + t.scn:3: error: duplicate shot name `a` (first used on line 2) + """ } - - #expect(error?.line == 3) - #expect(error?.message == "duplicate shot name `a` (first used on line 2)") } @Test func `shot names that differ only by case collide, because goldens are files`() { - let error = #expect(throws: ScenarioParseError.self) { - try parse("app stack\nshot a\ntype \"+\"\nshot A") + assertInlineSnapshot( + of: parseError(of: "app stack\nshot a\ntype \"+\"\nshot A"), as: .customDump + ) { + """ + ScenarioParseError( + file: "t.scn", + line: 4, + source: "shot A", + message: "shot name `A` collides with `a` on line 2: goldens are files, and the file systems CI runs on fold case" + ) + """ } - - #expect(error?.line == 4) - #expect(error?.source == "shot A") - #expect( - error?.description.hasPrefix("t.scn:4: error: shot name `A` collides with `a`") - == true) - #expect(error?.message.contains("on line 2") == true) - #expect(error?.message.contains("fold case") == true) } @Test @@ -240,19 +326,42 @@ struct ScenarioParserTests { 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\""]) + expectNoDifference(fromCRLF, fromLF) + assertInlineSnapshot(of: fromCRLF.lines, as: .customDump) { + """ + [ + [0]: ScenarioLine( + number: 4, + source: #"type "hi""#, + statement: .type("hi") + ), + [1]: ScenarioLine( + number: 5, + source: #"expect text 0 "x""#, + statement: .expectText( + row: 0, + text: "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") + assertInlineSnapshot( + of: parseError(of: "app stack\r\nsize 40x10\r\nblink\r\n"), as: .customDump + ) { + """ + ScenarioParseError( + file: "t.scn", + line: 3, + source: "blink", + message: "unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, expect text, expect screen, expect node" + ) + """ } - - #expect(error?.line == 3) - #expect(error?.source == "blink") } @Test @@ -266,27 +375,25 @@ struct ScenarioParserTests { """# ) - #expect( - scenario.lines.map(\.statement) == [ - .bytes([0x1B, 0x5B, 0x41]), .paste("a b\nc"), .type("AB"), - ]) + expectNoDifference( + 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) - } + let lines = [#"type "\x1""#, #"type "\xZZ""#, #"type "\x80""#, #"type "\q""#] - #expect( - error?.message.contains(expected) == true, "\(line): \(error?.message ?? "")") + assertInlineSnapshot( + of: parseFailures(of: lines.map { "app stack\n" + $0 }), as: .lines + ) { + #""" + t.scn:2: error: `\x` needs two hex digits, as in `\x1b` + t.scn:2: error: `\x` needs two hex digits, as in `\x1b` + t.scn:2: error: `\x` escapes are ASCII (00 to 7f); write a non-ASCII character itself + t.scn:2: error: unknown escape `\q`; accepted: \n \t \" \\ \xHH + """# } } @@ -305,92 +412,165 @@ struct ScenarioParserTests { """ ) - #expect(scenario.lines.count == 2) - #expect( - scenario.lines[0].statement - == .expectScreen(rows: ["Stack demo", " # not a comment", "", "last "])) - #expect(scenario.lines[0].number == 2) - #expect(scenario.lines[0].source == "expect screen <`, so text that names a file in it is + /// the same in every run. + /// + /// A path with backslashes appears in a dump with each one doubled, and in the JSON a dump + /// holds with each one doubled again, so every spelling of it is replaced, the longest first. + func redacted(_ text: String) -> String { + [4, 2, 1].reduce(text) { text, factor in + let spelled = path.replacingOccurrences( + of: "\\", with: String(repeating: "\\", count: factor)) + return text.replacingOccurrences(of: spelled, with: "") + } + } + + /// `value` as a custom dump with the directory's path redacted. + func dump(_ value: Value) -> String { + var text = "" + customDump(value, to: &text) + return redacted(text) + } + deinit { try? FileManager.default.removeItem(atPath: path) } diff --git a/Tests/TesseraHarnessTests/TerminalAnswererTests.swift b/Tests/TesseraHarnessTests/TerminalAnswererTests.swift index 945f4bc..aa7929a 100644 --- a/Tests/TesseraHarnessTests/TerminalAnswererTests.swift +++ b/Tests/TesseraHarnessTests/TerminalAnswererTests.swift @@ -1,3 +1,4 @@ +import CustomDump import TesseraTerminal import TesseraTerminalCore import Testing @@ -122,14 +123,13 @@ struct TerminalAnswererTests { 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) + let overrides = ["none", "16", "256", "true"].map { spelling in + TerminalColors(rawValue: spelling)?.override + } + + expectNoDifference( + overrides, + [.force(.noColor), .force(.ansi16), .force(.indexed256), .force(.truecolor)]) #expect(Set(TerminalColors.spellings) == Set(TerminalColors.allCases.map(\.rawValue))) #expect(Set(TerminalTheme.spellings) == Set(TerminalTheme.allCases.map(\.rawValue))) } From 4b96bcc2dd126e5d826fba52353b210dd2970b1f Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 12:56:12 -0400 Subject: [PATCH 31/55] refactor(harness): apply review conventions to the projection and checks Express the projection, checks, trace, replay, grid, statement, and demo tests as inline snapshots and expectNoDifference where they check structured state, sort the conformance lists of the new types, conform CostBound to CustomStringConvertible, and replace plan and slice references in comments and the changelog. --- CHANGELOG.md | 2 +- Examples/Sources/DemoSupport/TextDemo.swift | 4 +- .../DemoSupportTests/PrimitiveDemoTests.swift | 63 +- .../TesseraHarness/Projection/Checks.swift | 2 +- .../Projection/Projection.swift | 18 +- .../Projection/ScreenGrid.swift | 2 +- .../TesseraHarness/Scenarios/Statement.swift | 4 +- Sources/TesseraHarness/Trace/Trace.swift | 8 +- Sources/TesseraHarness/Trace/TraceFile.swift | 2 +- .../TesseraHarness/Trace/TraceWriter.swift | 2 +- Tests/TesseraHarnessTests/ChecksTests.swift | 242 +- .../HarnessCLIScenarioTests.swift | 870 ++++-- .../ProjectionFixtures.swift | 6 +- .../TesseraHarnessTests/ProjectionTests.swift | 682 ++++- Tests/TesseraHarnessTests/ReplayTests.swift | 310 +- .../ScenarioRunnerTests.swift | 2563 +++++++++++++++-- .../ScenarioStatementsTests.swift | 630 +++- Tests/TesseraHarnessTests/ScenarioTests.swift | 1208 ++++++-- .../TesseraHarnessTests/ScreenGridTests.swift | 147 +- Tests/TesseraHarnessTests/TraceTests.swift | 527 +++- Tests/TesseraHarnessTests/TypeNameTests.swift | 70 +- 21 files changed, 6277 insertions(+), 1085 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55f83f4..6303646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added the scenario language and its runner. `Examples/Scenarios/README.md` is the grammar (`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), with a table of the forms it names but cannot honour yet (`wait`, `settle`, `focus`, `mouse`, `expect cell`, …), each a parse error that says what it waits for. A header-binding, fail-fast parser reports `FILE:LINE: error:` and reads CRLF files; a runner continues after a failed expectation; `shot` compares a text golden beside the scenario and fails on drift unless `--record` writes it. `TesseraHarness` gains `ScenarioRunner`, `ScenarioReport`, and `ScenarioFailure` as its public run entry point, and answers a session's colour queries (OSC 10, 11, and 4, and DA1) from the scenario's `term` header. - Added `tessera run PATH...` (with `--record`, `--repeat N`, `--timing`), `tessera shot NAME [SIZE]`, and `tessera tree NAME [SIZE]` to `HarnessCLI`, each printing text on a terminal and one JSON object otherwise, with exit codes 0 pass, 1 an expectation failed, 2 a usage or parse error, and 3 a harness error. `help COMMAND` prints real text, and `mise run dev:run`, `dev:shot`, `dev:tree`, `dev:check`, `dev:replay`, and `dev:help` mirror the commands. - Added the scenario corpus, `Examples/Scenarios`, ported from the HeadlessRunner spike with a counter scenario, its text goldens, and the `workspace` demo it runs against, and made CI run `tessera run Examples/Scenarios` on every leg of the test matrix, Windows included, with goldens checked out with LF. -- Added the harness projection, `check`, `expect cost`, and the trace to `TesseraHarness`. A `Projection` is the view graph's diagnostics as sorted-key JSON (nodes with short `type` and `layout` names, frames, clips, proposals, measured sizes, requirements, and invalidation reasons, plus the statistics and the screen's rows), with a `version` and no promise of stability. Five rules v0 read it, and their ids are stable: `no-dirty-after-pass`, `frame-inside-clip`, `measured-within-proposal`, `siblings-disjoint`, and `requirements-aggregate`. The scenario language gains `dump`, `check [RULE...]` (an error fails the step and names the rule and the node; a warning is listed in the observation and fails only a rule you name), `expect cost NAME<=N` over `bodies`, `bytes`, `measures`, `placements`, and `renders`, and the locators `layout=` and `id=` (a user key or a full path) beside `type=`, `text=`, and `nth=`. +- Added the harness projection, `check`, `expect cost`, and the trace to `TesseraHarness`. A `Projection` is the view graph's diagnostics as sorted-key JSON (nodes with short `type` and `layout` names, frames, clips, proposals, measured sizes, requirements, and invalidation reasons, plus the statistics and the screen's rows), with a `version` and no promise of stability. Five rules read it, and their ids are stable: `no-dirty-after-pass`, `frame-inside-clip`, `measured-within-proposal`, `siblings-disjoint`, and `requirements-aggregate`. The scenario language gains `dump`, `check [RULE...]` (an error fails the step and names the rule and the node; a warning is listed in the observation and fails only a rule you name), `expect cost NAME<=N` over `bodies`, `bytes`, `measures`, `placements`, and `renders`, and the locators `layout=` and `id=` (a user key or a full path) beside `type=`, `text=`, and `nth=`. - Added `tessera check NAME [SIZE]` (every rule over one app at one size, exit 1 on an error) and `tessera replay FILE --app NAME`, and made `tessera run --trace DIR` and `TESSERA_TRACE` (for `launch`) write a JSONL trace, one record per step with the event, `drew`, the invalidation `reasons`, the changed nodes and screen rows, and the `cost`. `replay` delivers a trace's events to its app (keys, pastes, resizes, and the terminal's focus changes; a trace with a mouse event or a terminal reply is refused by name) and compares reasons, nodes, rows, and cost (bytes excepted), so a library change that moves work shows as a diff. `shot` now writes `NAME.grid.txt` beside `NAME.txt`: one row of text per screen row, then one row of styles, in the design catalog's grid dialect, and `--record` rewrites both; a screen with more than 62 distinct styles fails the `shot` as a harness error. `NAME.txt` itself now reads a wide grapheme as one character (`東京` is two characters over four columns), where the first scenario runner kept the blank cell after it, so a golden with wide text needs one `--record`. - Added `TerminalSession.bytesDrawn`, a `package` running total of the bytes the session's frames have written (the harness takes the difference across a step), so the harness measures a frame's cost without a lock or a tap. - Added demos for the landed primitives to `DemoSupport`, each with a desktop and a mobile scenario that shots both sizes and asserts a node's frame, and most of them run `check`: `text` (with the archived Unicode samples), `stacks` (`Spacer`, `Spacer(minLength:)`, a `Spacer` in a `VStack`, and `layoutPriority`; it absorbs the `stack` demo), `frame-padding`, `zstack` (layers from a `ForEach` and from an `if` group too), `flex` (two pages: the constraints, then a `layoutPriority` tier, a child with no `.flex` value, and cross-axis placement), and `foreach` (`siblings-disjoint` passes over `ForEach` items that are added, removed, and reversed). The corpus is 16 scenarios. diff --git a/Examples/Sources/DemoSupport/TextDemo.swift b/Examples/Sources/DemoSupport/TextDemo.swift index 11b5498..27cb495 100644 --- a/Examples/Sources/DemoSupport/TextDemo.swift +++ b/Examples/Sources/DemoSupport/TextDemo.swift @@ -10,8 +10,8 @@ import TesseraHarness /// `mobile` viewport, and a probe that `n` and `p` step through one sample at a time with the /// size it should measure. /// -/// Wrapping, truncation, and styled runs are not here: they arrive with the styling slice, and -/// the archived demo's wrap and truncation keys come back with them. `q` quits. +/// The demo shows measurement and placement only, with no wrapping, truncation, or styled runs. +/// `q` quits. final class TextDemo: Demo { /// One probe sample and the size it measures. private struct Sample { diff --git a/Examples/Tests/DemoSupportTests/PrimitiveDemoTests.swift b/Examples/Tests/DemoSupportTests/PrimitiveDemoTests.swift index dfe0d24..a9625f4 100644 --- a/Examples/Tests/DemoSupportTests/PrimitiveDemoTests.swift +++ b/Examples/Tests/DemoSupportTests/PrimitiveDemoTests.swift @@ -1,3 +1,4 @@ +import CustomDump import Tessera import Testing @@ -91,16 +92,15 @@ struct PrimitiveDemoTests { func `text measures wide and clustered content in cells`() async throws { try await Demos.registry().withHeadlessHost("text", at: .desktop) { host in let wide = host.projection.nodes.first { $0.text == "東京 中文 한글" } - #expect(wide?.frame == Projection.Box(column: 9, row: 3, columns: 14, rows: 1)) + expectNoDifference(wide?.frame, Projection.Box(column: 9, row: 3, columns: 14, rows: 1)) #expect(host.screenRows[3] == "wide 東京 中文 한글") - for (sample, columns) in [ - ("wide", 4), ("emoji", 2), ("combining", 1), ("halfwidth", 3), - ] { + var probes: [[Int]] = [] + for _ in ["wide", "emoji", "combining", "halfwidth"] { try await host.send(key("n")) let probe = host.projection.nodes.filter { $0.layout == "_FrameLayout" }[6] - #expect(probe.frame.columns == columns, "\(sample)") - #expect(probe.frame.rows == 2, "\(sample)") + probes.append([probe.frame.columns, probe.frame.rows]) } + expectNoDifference(probes, [[4, 2], [2, 2], [1, 2], [3, 2]]) } } @@ -112,12 +112,10 @@ struct PrimitiveDemoTests { let paddings = projection.nodes.filter { $0.layout == "_PaddingLayout" }.map( \.frame) - #expect(frames[0] == Projection.Box(column: 0, row: 3, columns: 8, rows: 3)) - #expect(frames[1].columns == 6) - #expect(frames[2].columns == 4) - #expect(frames[3].columns == 12) - #expect(paddings.map(\.columns) == [4, 5, 4, 10]) - #expect(paddings.map(\.rows) == [3, 2, 3, 3]) + expectNoDifference(frames[0], Projection.Box(column: 0, row: 3, columns: 8, rows: 3)) + expectNoDifference(frames[1...3].map(\.columns), [6, 4, 12]) + expectNoDifference(paddings.map(\.columns), [4, 5, 4, 10]) + expectNoDifference(paddings.map(\.rows), [3, 2, 3, 3]) } } @@ -128,19 +126,18 @@ struct PrimitiveDemoTests { try await Demos.registry().withHeadlessHost("frame-padding", at: .desktop) { host in // `+` and `-` step the uniform padding of the `pad` sample, which starts at one cell and // stops at zero and at three: 2n + 2 columns by 2n + 1 rows around `Go`. - for (pressed, columns, rows) in [ - ("+", 6, 5), ("+", 8, 7), ("+", 8, 7), ("-", 6, 5), - ] { + var sizes: [[Int?]] = [] + for pressed in ["+", "+", "+", "-"] { try await host.send(key(Character(pressed))) let padding = host.projection.nodes.first { $0.layout == "_PaddingLayout" } - #expect(padding?.frame.columns == columns, "after \(pressed)") - #expect(padding?.frame.rows == rows, "after \(pressed)") + sizes.append([padding?.frame.columns, padding?.frame.rows]) } + expectNoDifference(sizes, [[6, 5], [8, 7], [8, 7], [6, 5]]) for _ in 0..<5 { try await host.send(key("-")) } let none = host.projection.nodes.first { $0.layout == "_PaddingLayout" } - #expect(none?.frame == Projection.Box(column: 0, row: 7, columns: 2, rows: 1)) + expectNoDifference(none?.frame, Projection.Box(column: 0, row: 7, columns: 2, rows: 1)) } } @@ -149,7 +146,8 @@ struct PrimitiveDemoTests { try await Demos.registry().withHeadlessHost("zstack", at: .desktop) { host in #expect(host.screenRows[3].hasPrefix("OKatus Go······ bbAA")) let zstacks = host.projection.nodes.filter { $0.type == "ZStack" } - #expect(zstacks[3].frame == Projection.Box(column: 1, row: 7, columns: 0, rows: 0)) + expectNoDifference( + zstacks[3].frame, Projection.Box(column: 1, row: 7, columns: 0, rows: 0)) try await host.send(key("s")) #expect(host.screenRows[3].hasSuffix("AAAA")) #expect(host.screenRows[9].hasPrefix("alignment topLeading, A on top")) @@ -188,14 +186,14 @@ struct PrimitiveDemoTests { try await Demos.registry().withHeadlessHost("stacks", at: .desktop) { host in let spacers = host.projection.nodes.filter { $0.type == "Spacer" }.map( \.frame.columns) - #expect(spacers == [0, 78, 39, 38, 39, 38, 77, 0]) + expectNoDifference(spacers, [0, 78, 39, 38, 39, 38, 77, 0]) } // At 40 columns the share would be 19, so the floor of 20 binds and the plain Spacer is // left 17. try await Demos.registry().withHeadlessHost("stacks", at: .mobile) { host in let spacers = host.projection.nodes.filter { $0.type == "Spacer" }.map( \.frame.columns) - #expect(spacers == [0, 38, 19, 18, 20, 17, 37, 0]) + expectNoDifference(spacers, [0, 38, 19, 18, 20, 17, 37, 0]) } } @@ -203,7 +201,8 @@ struct PrimitiveDemoTests { func `a spacer in a vstack takes the rows between its neighbours`() async throws { try await Demos.registry().withHeadlessHost("stacks", at: .desktop) { host in let vertical = host.projection.nodes.first { $0.type == "Spacer" } - #expect(vertical?.frame == Projection.Box(column: 24, row: 9, columns: 0, rows: 1)) + expectNoDifference( + vertical?.frame, Projection.Box(column: 24, row: 9, columns: 0, rows: 1)) #expect(host.screenRows[8].hasSuffix("A")) #expect(host.screenRows[10].hasSuffix("B")) } @@ -220,14 +219,10 @@ struct PrimitiveDemoTests { ) { first, _ in first } #expect(host.screenRows[0] == "Flex: priority, defaults, cross axis") // Three equal fills split 75 cells; with a priority tier the third takes them all. - #expect(frames["E"]?.column == 27) - #expect(frames["H"]?.column == 2) - #expect(frames["I"]?.column == 4) // No flex value is weight 1 beside a fill(1): 38 cells each, the odd one to the first. - #expect(frames["K"]?.column == 41) + expectNoDifference(["E", "H", "I", "K", "◦"].map { frames[$0]?.column }, [27, 2, 4, 41, 0]) // A child smaller than the cross axis sits at the leading edge. - #expect(frames["•"] == Projection.Box(column: 2, row: 9, columns: 1, rows: 1)) - #expect(frames["◦"]?.column == 0) + expectNoDifference(frames["•"], Projection.Box(column: 2, row: 9, columns: 1, rows: 1)) let violations = Checker.run(CheckRule.allCases, on: host.projection) #expect(violations.isEmpty, "\(violations.map(\.text))") try await host.send(key("n")) @@ -242,10 +237,10 @@ struct PrimitiveDemoTests { // Children's origins after their predecessors' allocations: 4, 30, and 4 around two // spacing cells; 9 and 29 for the percentages of the 39 cells after the spacing; 13 and // 26; the equal fills 20 and 19; and the min child growing into the space left. - #expect( - modifiers.map(\.frame.column).prefix(11) == [ - 0, 5, 36, 0, 10, 0, 14, 0, 21, 0, 39, - ]) + expectNoDifference( + Array(modifiers.map(\.frame.column).prefix(11)), + [0, 5, 36, 0, 10, 0, 14, 0, 21, 0, 39] + ) } } @@ -260,9 +255,9 @@ struct PrimitiveDemoTests { } } try await Demos.registry().withHeadlessHost("foreach", at: .desktop) { host in - #expect(rows(host.projection) == [4, 5, 6]) + expectNoDifference(rows(host.projection), [4, 5, 6]) try await host.send(key("r")) - #expect(rows(host.projection) == [6, 5, 4]) + expectNoDifference(rows(host.projection), [6, 5, 4]) let violations = Checker.run([.siblingsDisjoint], on: host.projection) #expect(violations.isEmpty) } diff --git a/Sources/TesseraHarness/Projection/Checks.swift b/Sources/TesseraHarness/Projection/Checks.swift index 20ce9e2..11b6e57 100644 --- a/Sources/TesseraHarness/Projection/Checks.swift +++ b/Sources/TesseraHarness/Projection/Checks.swift @@ -58,7 +58,7 @@ package enum CheckRule: String, CaseIterable, Sendable { } /// One invariant that does not hold, on one node. -package struct Violation: Equatable, Encodable, Sendable { +package struct Violation: Encodable, Equatable, Sendable { /// The id of the rule, such as `siblings-disjoint`. package var rule: String diff --git a/Sources/TesseraHarness/Projection/Projection.swift b/Sources/TesseraHarness/Projection/Projection.swift index ecbec14..573bf1f 100644 --- a/Sources/TesseraHarness/Projection/Projection.swift +++ b/Sources/TesseraHarness/Projection/Projection.swift @@ -13,7 +13,7 @@ import TesseraTerminalCore /// renamed, or removed in any release, and the version says when. package struct Projection: Encodable, Equatable, Sendable { /// A cell rectangle, half-open like `Rect`. - struct Box: Equatable, Encodable, Sendable { + struct Box: Encodable, Equatable, Sendable { var column: Int var row: Int var columns: Int @@ -48,19 +48,19 @@ package struct Projection: Encodable, Equatable, Sendable { } /// A size in cells. - struct Extent: Equatable, Encodable, Sendable { + struct Extent: Encodable, Equatable, Sendable { var columns: Int var rows: Int } /// The size a node was offered; a missing axis was unconstrained. - struct Proposal: Equatable, Encodable, Sendable { + struct Proposal: Encodable, Equatable, Sendable { var width: Int? var height: Int? } /// One node of the view graph. - struct Node: Equatable, Encodable, Sendable { + struct Node: Encodable, Equatable, Sendable { /// The identity path, such as `root/index(2)/id(catalog)`; unique per node. var path: String var parent: String? @@ -104,21 +104,21 @@ package struct Projection: Encodable, Equatable, Sendable { } /// The terminal features the graph asks for. - struct Requirements: Equatable, Encodable, Sendable { + struct Requirements: Encodable, Equatable, Sendable { var effective: [String]? // swiftlint:disable:this discouraged_optional_collection var requested: [String] } /// The screen the pass drew. - struct Screen: Equatable, Encodable, Sendable { + struct Screen: Encodable, Equatable, Sendable { /// Where the cursor is. - struct Cursor: Equatable, Encodable, Sendable { + struct Cursor: Encodable, Equatable, Sendable { var column: Int var row: Int } /// A run of non-default style. - struct StyleRun: Equatable, Encodable, Sendable { + struct StyleRun: Encodable, Equatable, Sendable { var row: Int var column: Int var columns: Int @@ -135,7 +135,7 @@ package struct Projection: Encodable, Equatable, Sendable { } /// The pass counters. Durations are left out: they are not deterministic. - struct Statistics: Equatable, Encodable, Sendable { + struct Statistics: Encodable, Equatable, Sendable { var bodyEvaluations: Int var equatableSkips: Int var focusChanges: Int diff --git a/Sources/TesseraHarness/Projection/ScreenGrid.swift b/Sources/TesseraHarness/Projection/ScreenGrid.swift index 396a902..d731379 100644 --- a/Sources/TesseraHarness/Projection/ScreenGrid.swift +++ b/Sources/TesseraHarness/Projection/ScreenGrid.swift @@ -25,7 +25,7 @@ package struct ScreenGrid: Equatable, Sendable { } /// A screen that cannot be written as a grid. - package struct GridError: Error, Equatable, CustomStringConvertible { + package struct GridError: CustomStringConvertible, Equatable, Error { /// How many distinct styles the screen has. var styles: Int diff --git a/Sources/TesseraHarness/Scenarios/Statement.swift b/Sources/TesseraHarness/Scenarios/Statement.swift index 2e9678a..79dc6f9 100644 --- a/Sources/TesseraHarness/Scenarios/Statement.swift +++ b/Sources/TesseraHarness/Scenarios/Statement.swift @@ -14,14 +14,14 @@ package struct FrameExpectation: CustomStringConvertible, Equatable, Sendable { } /// One bound of an `expect cost` statement: `bodies<=5`. -package struct CostBound: Equatable, Sendable { +package struct CostBound: CustomStringConvertible, Equatable, Sendable { /// The counter, one of ``StepCost/names``. package var counter: String /// The most the counter may be. package var limit: Int - var description: String { + package var description: String { "\(counter)<=\(limit)" } } diff --git a/Sources/TesseraHarness/Trace/Trace.swift b/Sources/TesseraHarness/Trace/Trace.swift index 6395836..b0fe2f8 100644 --- a/Sources/TesseraHarness/Trace/Trace.swift +++ b/Sources/TesseraHarness/Trace/Trace.swift @@ -8,7 +8,7 @@ import TesseraTerminalInput /// The counts are deterministic, so a scenario can bound them with `expect cost`. They are the /// library's work and not a promise: a change that makes the library do less work lowers /// them, so bound them with `<=` and never assert an exact number. -package struct StepCost: Equatable, Codable, Sendable { +package struct StepCost: Codable, Equatable, Sendable { /// The counters `expect cost` accepts, in the order the grammar lists them. package static let names = ["bodies", "bytes", "measures", "placements", "renders"] @@ -52,7 +52,7 @@ package struct StepCost: Equatable, Codable, Sendable { /// `focusGained` or `focusLost` and replays like the others. An event neither the grammar nor /// replay can deliver (a mouse event before pointer input exists, a terminal reply) is /// recorded as `other`, so the trace stays complete and `replay` can refuse it by name. -package struct TraceEvent: Equatable, Codable, Sendable { +package struct TraceEvent: Codable, Equatable, Sendable { /// `key`, `paste`, `resize`, `focusGained`, `focusLost`, or `other`. package var type: String @@ -154,9 +154,9 @@ package struct TraceEvent: Equatable, Codable, Sendable { /// A record is one line of the trace file, a sorted-key JSON object. "Why did this redraw?" is /// read from `reasons`: the library's invalidation reasons for the pass, which are value-free /// and bounded. The record carries a `version` and is not part of the 1.0 promise. -package struct TraceRecord: Equatable, Codable, Sendable { +package struct TraceRecord: Codable, Equatable, Sendable { /// The nodes a step changed, by identity path. - package struct NodeChanges: Equatable, Codable, Sendable { + package struct NodeChanges: Codable, Equatable, Sendable { /// Nodes that did not exist before the step. package var added: [String] diff --git a/Sources/TesseraHarness/Trace/TraceFile.swift b/Sources/TesseraHarness/Trace/TraceFile.swift index 22144c9..19de1d5 100644 --- a/Sources/TesseraHarness/Trace/TraceFile.swift +++ b/Sources/TesseraHarness/Trace/TraceFile.swift @@ -12,7 +12,7 @@ enum TraceFile { } /// A line of a trace file that cannot be read back. - struct ReadError: Error, Equatable, CustomStringConvertible { + struct ReadError: CustomStringConvertible, Equatable, Error { /// The line's number, counted from 1. var line: Int var message: String diff --git a/Sources/TesseraHarness/Trace/TraceWriter.swift b/Sources/TesseraHarness/Trace/TraceWriter.swift index 982d17f..dcb0d72 100644 --- a/Sources/TesseraHarness/Trace/TraceWriter.swift +++ b/Sources/TesseraHarness/Trace/TraceWriter.swift @@ -9,7 +9,7 @@ import Foundation @MainActor final class TraceWriter { /// A trace file that cannot be created. - struct OpenError: Error, CustomStringConvertible { + struct OpenError: CustomStringConvertible, Error { var path: String var reason: String diff --git a/Tests/TesseraHarnessTests/ChecksTests.swift b/Tests/TesseraHarnessTests/ChecksTests.swift index d0863ee..f976bfe 100644 --- a/Tests/TesseraHarnessTests/ChecksTests.swift +++ b/Tests/TesseraHarnessTests/ChecksTests.swift @@ -1,3 +1,7 @@ +import CustomDump +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump import Testing @testable import TesseraHarness @@ -70,12 +74,14 @@ struct ChecksTests { dirty: (layout: true, render: true)), ])) - #expect( - found == [ + expectNoDifference( + found, + [ Violation( rule: "no-dirty-after-pass", severity: "error", node: "root/a", detail: "still dirty after pass 3: needsLayout, needsRender") - ]) + ] + ) } @Test @@ -94,12 +100,18 @@ struct ChecksTests { node("root", frame: (0, 0, 10, 1), clip: (0, 0, 4, 1), text: "Hello, world") ])) - #expect(found.map(\.severity) == ["warning"]) - #expect(found.map(\.node) == ["root"]) - #expect( - found.map(\.detail) == [ - "frame 0,0,10x1 is clipped to 0,0,4x1 (visible text `Hello, world`)" - ]) + assertInlineSnapshot(of: found, as: .customDump) { + """ + [ + [0]: Violation( + rule: "frame-inside-clip", + severity: "warning", + node: "root", + detail: "frame 0,0,10x1 is clipped to 0,0,4x1 (visible text `Hello, world`)" + ) + ] + """ + } } @Test @@ -108,7 +120,18 @@ struct ChecksTests { .frameInsideClip, projection([node("root", frame: (5, 0, 3, 1), clip: (0, 0, 4, 1))])) - #expect(found.map(\.detail) == ["frame 5,0,3x1 is clipped to nothing"]) + assertInlineSnapshot(of: found, as: .customDump) { + """ + [ + [0]: Violation( + rule: "frame-inside-clip", + severity: "warning", + node: "root", + detail: "frame 5,0,3x1 is clipped to nothing" + ) + ] + """ + } } @Test @@ -144,11 +167,18 @@ struct ChecksTests { measured: (columns: 12, rows: 3)) ])) - #expect( - found.map(\.detail) == [ - "measured 12 columns where 4 were offered and 3 rows where 1 were offered" - ]) - #expect(found.map(\.severity) == ["error"]) + assertInlineSnapshot(of: found, as: .customDump) { + """ + [ + [0]: Violation( + rule: "measured-within-proposal", + severity: "error", + node: "root", + detail: "measured 12 columns where 4 were offered and 3 rows where 1 were offered" + ) + ] + """ + } } @Test @@ -194,14 +224,16 @@ struct ChecksTests { node("root/id(b)", parent: "root", frame: (0, 0, 4, 1)), ])) - #expect( - found == [ + expectNoDifference( + found, + [ Violation( rule: "siblings-disjoint", severity: "error", node: "root/id(b)", detail: "shows 0,0,4x1, which overlaps sibling root/id(a) showing 0,0,4x1 under VStack" ) - ]) + ] + ) } @Test @@ -236,8 +268,18 @@ struct ChecksTests { } #expect(items(under: "ZStack").isEmpty) - #expect(items(under: "VStack").map(\.node) == ["root/index(0)/id(b)"]) - #expect(items(under: "VStack").first?.detail.hasSuffix("under ForEach") == true) + assertInlineSnapshot(of: items(under: "VStack"), as: .customDump) { + """ + [ + [0]: Violation( + rule: "siblings-disjoint", + severity: "error", + node: "root/index(0)/id(b)", + detail: "shows 0,0,2x1, which overlaps sibling root/index(0)/id(a) showing 0,0,4x1 under ForEach" + ) + ] + """ + } } @Test @@ -271,11 +313,26 @@ struct ChecksTests { #expect(run(.siblingsDisjoint, tree(under: "ZStack")).isEmpty) // Under a stack the same two texts on one frame are the defect, and so is the text // that the optional puts over `base`. - #expect( - run(.siblingsDisjoint, tree(under: "VStack")).map(\.node) == [ - "root/index(1)", - "root/index(1)/branch(true)/index(1)", - ]) + assertInlineSnapshot( + of: run(.siblingsDisjoint, tree(under: "VStack")), as: .customDump + ) { + """ + [ + [0]: Violation( + rule: "siblings-disjoint", + severity: "error", + node: "root/index(1)", + detail: "shows 0,0,1x1, which overlaps sibling root/index(0) showing 0,0,4x1 under VStack" + ), + [1]: Violation( + rule: "siblings-disjoint", + severity: "error", + node: "root/index(1)/branch(true)/index(1)", + detail: "shows 0,0,1x1, which overlaps sibling root/index(1)/branch(true)/index(0) showing 0,0,1x1 under TupleView" + ) + ] + """ + } } @Test @@ -325,7 +382,18 @@ struct ChecksTests { node("root/index(1)", parent: "root", frame: (0, 0, 4, 1)), ])) - #expect(found.map(\.node) == ["root/index(1)"]) + assertInlineSnapshot(of: found, as: .customDump) { + """ + [ + [0]: Violation( + rule: "siblings-disjoint", + severity: "error", + node: "root/index(1)", + detail: "shows 0,0,4x1, which overlaps sibling root/index(0) showing 0,0,4x1 under TupleView" + ) + ] + """ + } } @Test @@ -355,10 +423,20 @@ struct ChecksTests { ]) } - #expect( - run(.siblingsDisjoint, tree(userViewIsList: false)).map(\.node) == [ - "root/index(0)/index(0)/index(1)" - ]) + assertInlineSnapshot( + of: run(.siblingsDisjoint, tree(userViewIsList: false)), as: .customDump + ) { + """ + [ + [0]: Violation( + rule: "siblings-disjoint", + severity: "error", + node: "root/index(0)/index(0)/index(1)", + detail: "shows 0,0,2x1, which overlaps sibling root/index(0)/index(0)/index(0) showing 0,0,4x1 under TupleView" + ) + ] + """ + } #expect( run(.siblingsDisjoint, tree(userViewIsList: true)).isEmpty, "the library's own ForEach in that place is a list, and is looked through") @@ -377,8 +455,18 @@ struct ChecksTests { node("root/b", parent: "root", frame: (2, 0, 4, 1)), ])) - #expect(found.map(\.node) == ["root/b"]) - #expect(found.map(\.detail).first?.hasSuffix("under Stagger") == true) + assertInlineSnapshot(of: found, as: .customDump) { + """ + [ + [0]: Violation( + rule: "siblings-disjoint", + severity: "error", + node: "root/b", + detail: "shows 2,0,4x1, which overlaps sibling root/a showing 0,0,4x1 under Stagger" + ) + ] + """ + } } @Test @@ -422,12 +510,14 @@ struct ChecksTests { node("root/a", parent: "root", frame: (0, 0, 4, 1), requirements: ["mouse"]), ], requested: [])) - #expect( - found == [ + expectNoDifference( + found, + [ Violation( rule: "requirements-aggregate", severity: "error", node: "root/a", detail: "asks for `mouse`, which the graph does not request") - ]) + ] + ) } @Test @@ -438,8 +528,18 @@ struct ChecksTests { .requirementsAggregate, projection([node("root", frame: (0, 0, 4, 1))], requested: ["focus"])) - #expect(found.map(\.node) == ["root"]) - #expect(found.map(\.detail) == ["the graph requests `focus` and no node asks for it"]) + assertInlineSnapshot(of: found, as: .customDump) { + """ + [ + [0]: Violation( + rule: "requirements-aggregate", + severity: "error", + node: "root", + detail: "the graph requests `focus` and no node asks for it" + ) + ] + """ + } } @Test @@ -474,30 +574,72 @@ struct ChecksTests { let backward = Checker.run( [.siblingsDisjoint, .frameInsideClip, .noDirtyAfterPass], on: crafted) - #expect( - forward.map(\.rule) == [ - "no-dirty-after-pass", "frame-inside-clip", "siblings-disjoint", - ]) - #expect( - backward.map(\.rule) == [ - "siblings-disjoint", "frame-inside-clip", "no-dirty-after-pass", - ]) - #expect(forward.map(\.severity) == ["error", "warning", "error"]) + assertInlineSnapshot(of: forward, as: .customDump) { + """ + [ + [0]: Violation( + rule: "no-dirty-after-pass", + severity: "error", + node: "root", + detail: "still dirty after pass 3: needsLayout" + ), + [1]: Violation( + rule: "frame-inside-clip", + severity: "warning", + node: "root/a", + detail: "frame 0,0,4x1 is clipped to 0,0,2x1" + ), + [2]: Violation( + rule: "siblings-disjoint", + severity: "error", + node: "root/b", + detail: "shows 0,0,4x1, which overlaps sibling root/a showing 0,0,2x1 under VStack" + ) + ] + """ + } + assertInlineSnapshot(of: backward, as: .customDump) { + """ + [ + [0]: Violation( + rule: "siblings-disjoint", + severity: "error", + node: "root/b", + detail: "shows 0,0,4x1, which overlaps sibling root/a showing 0,0,2x1 under VStack" + ), + [1]: Violation( + rule: "frame-inside-clip", + severity: "warning", + node: "root/a", + detail: "frame 0,0,4x1 is clipped to 0,0,2x1" + ), + [2]: Violation( + rule: "no-dirty-after-pass", + severity: "error", + node: "root", + detail: "still dirty after pass 3: needsLayout" + ) + ] + """ + } #expect(Checker.run([], on: crafted).isEmpty) } @Test func `the rule ids are the five the catalog names, in the order bare check runs them`() { - #expect( - CheckRule.allCases.map(\.rawValue) == [ + expectNoDifference( + CheckRule.allCases.map(\.rawValue), + [ "no-dirty-after-pass", "frame-inside-clip", "measured-within-proposal", "siblings-disjoint", "requirements-aggregate", - ]) + ] + ) #expect( CheckRule.accepted == "no-dirty-after-pass, frame-inside-clip, measured-within-proposal, " + "siblings-disjoint, requirements-aggregate") - #expect(CheckRule.allCases.filter { $0.severity == .warning } == [.frameInsideClip]) + expectNoDifference( + CheckRule.allCases.filter { $0.severity == .warning }, [.frameInsideClip]) } } diff --git a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift index f6d6c38..1ddd51b 100644 --- a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift @@ -1,4 +1,8 @@ +import CustomDump import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump import TesseraTerminal import TesseraTerminalCore import Testing @@ -48,17 +52,18 @@ struct HarnessCLIScenarioTests { 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 }) + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + {"command":"run","exitCode":0,"scenarios":[{"failures":[],"observations":[],"passed":true,"scenario":"/a.scn"},{"failures":[],"observations":[],"passed":true,"scenario":"/b.scn"}]} + + """, + stderr: "" + ) + """# + } } @Test @@ -69,12 +74,31 @@ struct HarnessCLIScenarioTests { 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) + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 1, + stdout: """ + PASS /a.scn + FAIL /b.scn + line 3: expect text 0 "Stack demos" + row 0 text differs + expected: Stack demos + actual: Stack demo + screen: + 0| Stack demo + 1| ──────────────────────────────────────── + 2| Name: - + 3| left right + 4| ──────────────────────────────────────── + 5| 40 columns + 2 scenarios, 1 passed, 1 failed + + """, + stderr: "" + ) + """# + } } @Test @@ -84,7 +108,19 @@ struct HarnessCLIScenarioTests { let output = await run("run", scratch.path, "--pretty") - #expect(output.stdout.hasSuffix("1 scenario, 1 passed, 0 failed\n")) + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + PASS /a.scn + 1 scenario, 1 passed, 0 failed + + """, + stderr: "" + ) + """# + } } @Test @@ -96,14 +132,18 @@ struct HarnessCLIScenarioTests { 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") + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 1, + stdout: """ + {"command":"run","exitCode":1,"scenarios":[{"failures":[{"actual":"Stack demo","expected":"Stack demos","line":3,"message":"row 0 text differs","screen":["Stack demo","────────────────────────────────────────","Name: -","left right","────────────────────────────────────────","40 columns"],"statement":"expect text 0 \"Stack demos\"","verb":"expect text"}],"observations":[],"passed":false,"scenario":"/b.scn"}]} + + """, + stderr: "" + ) + """# + } } @Test @@ -113,13 +153,18 @@ struct HarnessCLIScenarioTests { 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") + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 2, + stdout: """ + {"command":"run","exitCode":2,"scenarios":[{"failures":[{"line":2,"message":"unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost","statement":"blink","verb":"parse"}],"observations":[],"passed":false,"scenario":"/bad.scn"}]} + + """, + stderr: "" + ) + """# + } } @Test @@ -128,10 +173,18 @@ struct HarnessCLIScenarioTests { 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") + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 2, + stdout: """ + {"command":"run","exitCode":2,"scenarios":[{"failures":[{"line":0,"message":"no such file or directory","statement":"","verb":"usage"}],"observations":[],"passed":false,"scenario":"/missing.scn"}]} + + """, + stderr: "" + ) + """# + } } @Test @@ -164,18 +217,36 @@ struct HarnessCLIScenarioTests { let output = await run("run", scratch.path, registry: registry) - #expect(output.exitCode == 3) + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 3, + stdout: """ + {"command":"run","exitCode":3,"scenarios":[{"failures":[{"line":0,"message":"applications registered more than once: `stack`","statement":"","verb":"harness"}],"observations":[],"passed":false,"scenario":"/a.scn"}]} + + """, + stderr: "" + ) + """# + } } @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) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: """ + {"command":"run","exitCode":2,"message":"usage: run PATH... [--record] [--repeat N] [--timing]"} + + """, + stderr: "" + ) + """# + } } @Test @@ -186,16 +257,52 @@ struct HarnessCLIScenarioTests { 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, --trace\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")) + assertInlineSnapshot(of: [unknown, noValue, zero, word, twice], as: .customDump) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown flag `--frobnicate`; accepted: --json, --pretty, --record, --repeat, --timing, --trace + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--repeat` needs a value + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--repeat` needs a whole number of at least 1, got `0` + + """ + ), + [3]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--repeat` needs a whole number of at least 1, got `many` + + """ + ), + [4]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--repeat` is given twice + + """ + ) + ] + """# + } } @Test @@ -204,10 +311,18 @@ struct HarnessCLIScenarioTests { { let output = await run("run", "--repeat", "x.scn", "--pretty") - #expect(output.exitCode == 2) - #expect( - output.stderr - == "error: `--repeat` needs a whole number of at least 1, got `x.scn`\n") + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--repeat` needs a whole number of at least 1, got `x.scn` + + """ + ) + """# + } } @Test @@ -245,32 +360,41 @@ struct HarnessCLIScenarioTests { 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 - - """) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + Stack demo + ──────────────────────────────────────── + Name: - + left right + ──────────────────────────────────────── + 40 columns + + """, + stderr: "" + ) + """# + } } @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) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + {"app":"stack","command":"shot","exitCode":0,"rows":["Stack demo","────────────────────────────────────────","Name: -","left right","────────────────────────────────────────","40 columns"],"size":"40x6"} + + """, + stderr: "" + ) + """# + } } @Test @@ -286,9 +410,30 @@ struct HarnessCLIScenarioTests { 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")) + assertInlineSnapshot(of: text, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + root TupleView frame=0,0,40x8 + root/index(0) VStack frame=0,0,40x8 + root/index(0)/index(0) Text frame=0,0,10x1 + root/index(0)/index(1) Divider frame=0,1,40x1 + root/index(0)/index(2) HStack frame=0,2,7x1 + root/index(0)/index(2)/index(0) Text frame=0,2,5x1 + root/index(0)/index(2)/index(1) Text frame=6,2,1x1 + root/index(0)/index(3) HStack frame=0,3,40x1 + root/index(0)/index(3)/index(0) Text frame=0,3,4x1 + root/index(0)/index(3)/index(1) Spacer frame=4,3,31x0 clip=4,3,0x0 + root/index(0)/index(3)/index(2) Text frame=35,3,5x1 + root/index(0)/index(4) Divider frame=0,4,40x1 + root/index(0)/index(5) Text frame=0,5,10x1 + + """, + stderr: "" + ) + """# + } let object = try object(json) #expect(Set(object.keys) == ["app", "command", "exitCode", "lines", "size"]) #expect( @@ -313,15 +458,62 @@ struct HarnessCLIScenarioTests { 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) + assertInlineSnapshot( + of: [unknown, badSize, none, extra, greedy, duplicate], as: .customDump + ) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown app `ghost`; accepted: counter, event-log, greedy, main-actor-counter, observed-counter, stack, workspace + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: bad size `huge`; expected WxH, desktop, mobile, or mobileScreen + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: usage: shot NAME [SIZE] + + """ + ), + [3]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: usage: shot NAME [SIZE] + + """ + ), + [4]: CommandOutput( + exitCode: 2, + stdout: """ + {"command":"shot","exitCode":2,"message":"`greedy` has no natural height: it measured 500 rows when offered 500 and 1000 when offered 1000. Use `size mobileScreen` (40x16) or an explicit WxH"} + + """, + stderr: "" + ), + [5]: CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: applications registered more than once: `stack` + + """ + ) + ] + """# + } } // MARK: check @@ -339,18 +531,18 @@ struct HarnessCLIScenarioTests { { let output = await projectionRun("check", "layers", "mobile") - #expect(output.exitCode == 0) - let object = try object(output) - #expect(object["command"] as? String == "check") - #expect(object["app"] as? String == "layers") - #expect(object["exitCode"] as? Int == 0) - #expect(object["size"] as? String == "40x6") - #expect( - object["rules"] as? [String] == [ - "no-dirty-after-pass", "frame-inside-clip", "measured-within-proposal", - "siblings-disjoint", "requirements-aggregate", - ]) - #expect((object["violations"] as? [Any])?.isEmpty == true) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + {"app":"layers","command":"check","exitCode":0,"rules":["no-dirty-after-pass","frame-inside-clip","measured-within-proposal","siblings-disjoint","requirements-aggregate"],"size":"40x6","violations":[]} + + """, + stderr: "" + ) + """# + } } @Test @@ -359,13 +551,18 @@ struct HarnessCLIScenarioTests { { let output = await projectionRun("check", "overlap") - #expect(output.exitCode == 1) - let violations = try #require(try object(output)["violations"] as? [[String: String]]) - #expect(violations.count == 1) - #expect(violations[0]["rule"] == "siblings-disjoint") - #expect(violations[0]["severity"] == "error") - #expect(violations[0]["detail"]?.hasSuffix("under Overlap") == true) - #expect(violations[0]["node"]?.hasPrefix("root") == true) + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 1, + stdout: """ + {"app":"overlap","command":"check","exitCode":1,"rules":["no-dirty-after-pass","frame-inside-clip","measured-within-proposal","siblings-disjoint","requirements-aggregate"],"size":"80x24","violations":[{"detail":"shows 0,0,6x1, which overlaps sibling root/index(0)/index(0) showing 0,0,5x1 under Overlap","node":"root/index(0)/index(1)","rule":"siblings-disjoint","severity":"error"}]} + + """, + stderr: "" + ) + """# + } } @Test @@ -376,17 +573,40 @@ struct HarnessCLIScenarioTests { let text = await projectionRun("check", "overflow", "--pretty") let passing = await projectionRun("check", "layers", "--pretty") - let severities = (try? object(json)["violations"] as? [[String: String]])?.compactMap - { - $0["severity"] + assertInlineSnapshot(of: [json, text, passing], as: .customDump) { + #""" + [ + [0]: CommandOutput( + exitCode: 1, + stdout: """ + {"app":"overflow","command":"check","exitCode":1,"rules":["no-dirty-after-pass","frame-inside-clip","measured-within-proposal","siblings-disjoint","requirements-aggregate"],"size":"80x24","violations":[{"detail":"frame 0,0,12x1 is clipped to 0,0,4x1 (visible text `wide`)","node":"root/index(0)/index(0)/index(0)","rule":"frame-inside-clip","severity":"warning"},{"detail":"measured 12 columns where 4 were offered","node":"root/index(0)/index(0)/index(0)","rule":"measured-within-proposal","severity":"error"}]} + + """, + stderr: "" + ), + [1]: CommandOutput( + exitCode: 1, + stdout: """ + check overflow at 80x24 against 5 rules: no-dirty-after-pass, frame-inside-clip, measured-within-proposal, siblings-disjoint, requirements-aggregate + warning frame-inside-clip root/index(0)/index(0)/index(0): frame 0,0,12x1 is clipped to 0,0,4x1 (visible text `wide`) + error measured-within-proposal root/index(0)/index(0)/index(0): measured 12 columns where 4 were offered + FAIL overflow: 1 error, 1 warning + + """, + stderr: "" + ), + [2]: CommandOutput( + exitCode: 0, + stdout: """ + check layers at 80x24 against 5 rules: no-dirty-after-pass, frame-inside-clip, measured-within-proposal, siblings-disjoint, requirements-aggregate + PASS layers + + """, + stderr: "" + ) + ] + """# } - #expect(severities == ["warning", "error"]) - #expect(text.exitCode == 1) - #expect(text.stdout.contains(" warning frame-inside-clip ")) - #expect(text.stdout.contains(" error measured-within-proposal ")) - #expect(text.stdout.hasSuffix("FAIL overflow: 1 error, 1 warning\n")) - #expect(passing.stdout.hasPrefix("check layers at 80x24 against 5 rules: no-dirty")) - #expect(passing.stdout.hasSuffix("PASS layers\n")) } @Test @@ -400,15 +620,54 @@ struct HarnessCLIScenarioTests { let duplicate = await run("check", "stack", "--pretty", registry: duplicated) let greedy = await run("check", "greedy", "mobile", "--pretty") - #expect(noApp.exitCode == 2) - #expect(noApp.stderr == "error: usage: check NAME [SIZE]\n") - #expect(badSize.stderr.contains("bad size `huge`")) - #expect(unknown.exitCode == 2) - #expect(unknown.stderr.hasPrefix("error: unknown app `ghost`; accepted: ")) - #expect(duplicate.exitCode == 3) - #expect(duplicate.stderr.contains("registered more than once")) - #expect(greedy.exitCode == 2) - #expect(greedy.stderr.contains("mobileScreen")) + assertInlineSnapshot( + of: [noApp, badSize, unknown, duplicate, greedy], as: .customDump + ) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: usage: check NAME [SIZE] + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: bad size `huge`; expected WxH, desktop, mobile, or mobileScreen + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown app `ghost`; accepted: counter, event-log, greedy, main-actor-counter, observed-counter, stack, workspace + + """ + ), + [3]: CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: applications registered more than once: `stack` + + """ + ), + [4]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `greedy` has no natural height: it measured 500 rows when offered 500 and 1000 when offered 1000. Use `size mobileScreen` (40x16) or an explicit WxH + + """ + ) + ] + """# + } } @Test @@ -420,13 +679,25 @@ struct HarnessCLIScenarioTests { let output = await projectionRun("run", scratch.path, "--pretty") - #expect(output.exitCode == 1, "the error of the same check fails the run") - let lines = output.stdout.split(separator: "\n").map(String.init) - let warning = try #require(lines.firstIndex { $0.hasPrefix(" line 2: warning ") }) - let failure = try #require(lines.firstIndex(of: " line 2: check")) - #expect(lines[warning].contains("frame-inside-clip root")) - #expect(warning < failure, "the warnings come before the failures") - #expect(lines[failure + 1].hasPrefix(" measured-within-proposal at root")) + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 1, + stdout: """ + FAIL /w.scn + line 2: warning frame-inside-clip root/index(0)/index(0)/index(0): frame 0,0,12x1 is clipped to 0,0,4x1 (visible text `wide`) + line 2: check + measured-within-proposal at root/index(0)/index(0)/index(0): measured 12 columns where 4 were offered + screen: + 0| wide + 1| ok + 1 scenario, 0 passed, 1 failed + + """, + stderr: "" + ) + """# + } } @Test @@ -446,10 +717,17 @@ struct HarnessCLIScenarioTests { line: 3, verb: "shot", name: "a", lines: ["warning: not a check"]), ]) - #expect( - report.description - == "PASS flex.scn\n line 7: warning frame-inside-clip root/a: shows 0,0,9x1") - #expect(report.json.contains("warning frame-inside-clip root/a")) + assertInlineSnapshot(of: report.description, as: .lines) { + """ + PASS flex.scn + line 7: warning frame-inside-clip root/a: shows 0,0,9x1 + """ + } + assertInlineSnapshot(of: report.json, as: .lines) { + """ + {"failures":[],"observations":[{"line":7,"lines":["ran no-dirty-after-pass, frame-inside-clip","warning frame-inside-clip root/a: shows 0,0,9x1"],"verb":"check"},{"line":9,"lines":["ran no-dirty-after-pass"],"verb":"check"},{"line":3,"lines":["warning: not a check"],"name":"a","verb":"shot"}],"passed":true,"scenario":"flex.scn"} + """ + } } @Test @@ -470,17 +748,87 @@ struct HarnessCLIScenarioTests { let app = await run("replay", "t.jsonl", "--app", "", "--pretty") let json = await run("run", scratch.path, "--trace", "") - for output in [trace, path, inside, shot, tree, check, file, app, json] { - #expect(output.exitCode == 2, "\(output.stderr)\(output.stdout)") + assertInlineSnapshot( + of: scratch.dump([trace, path, inside, shot, tree, check, file, app, json]), + as: .lines + ) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--trace` needs a value that is not empty + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name + + """ + ), + [3]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name + + """ + ), + [4]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name + + """ + ), + [5]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name + + """ + ), + [6]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name + + """ + ), + [7]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--app` needs a value that is not empty + + """ + ), + [8]: CommandOutput( + exitCode: 2, + stdout: """ + {"command":"run","exitCode":2,"message":"`--trace` needs a value that is not empty"} + + """, + stderr: "" + ) + ] + """# } - #expect(trace.stderr == "error: `--trace` needs a value that is not empty\n") - #expect(trace.stdout.isEmpty, "no scenario ran") - #expect( - path.stderr == "error: an empty argument is not a path or an application name\n") - #expect(app.stderr == "error: `--app` needs a value that is not empty\n") - #expect( - try object(json)["message"] as? String - == "`--trace` needs a value that is not empty") #expect(!FileManager.default.fileExists(atPath: out)) } @@ -507,7 +855,20 @@ struct HarnessCLIScenarioTests { let output = await run("run", scratch.path, "--trace", out, "--pretty") - #expect(output.exitCode == 0) + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + PASS /a.scn + PASS /nested/b.scn + 2 scenarios, 2 passed, 0 failed + + """, + stderr: "" + ) + """# + } let files = [ TraceFile.path(forScenario: scratch.appending("a.scn"), in: out), TraceFile.path(forScenario: scratch.appending("nested/b.scn"), in: out), @@ -518,8 +879,9 @@ struct HarnessCLIScenarioTests { String(validating: $0, as: UTF8.self) }, "no trace at \(file)") let records = try TraceFile.records(in: text) - #expect(records.map(\.seq) == [0, 1, 2, 3, 4, 5]) - #expect(records[4].event == TraceEvent(.resize(TerminalSize(columns: 30, rows: 8)))) + expectNoDifference(records.map(\.seq), [0, 1, 2, 3, 4, 5]) + expectNoDifference( + records[4].event, TraceEvent(.resize(TerminalSize(columns: 30, rows: 8)))) } } @@ -535,14 +897,18 @@ struct HarnessCLIScenarioTests { let output = await run("replay", file, "--app", "stack") - #expect(output.exitCode == 0, "\(output.stdout)") - let object = try object(output) - #expect(object["command"] as? String == "replay") - let report = try #require((object["scenarios"] as? [[String: Any]])?.first) - #expect(report["passed"] as? Bool == true) - #expect(report["scenario"] as? String == file) - let final = (report["observations"] as? [[String: Any]])?.last?["lines"] as? [String] - #expect(final?.first == "Stack demo") + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + {"command":"replay","exitCode":0,"scenarios":[{"failures":[],"observations":[{"line":0,"lines":["Stack demo","──────────────────────────────","Name: Ro","left right","──────────────────────────────","30 columns"],"verb":"replay"}],"passed":true,"scenario":"/traces/a.trace.jsonl"}]} + + """, + stderr: "" + ) + """# + } } @Test @@ -559,10 +925,104 @@ struct HarnessCLIScenarioTests { let output = await run("replay", file, "--app", "stack", "--pretty", registry: other) - #expect(output.exitCode == 1) - #expect(output.stdout.hasPrefix("FAIL \(file)\n")) - #expect(output.stdout.contains("line 1: step 0: resize 40x10")) - #expect(output.stdout.contains("differ from the recording")) + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 1, + stdout: """ + FAIL /traces/a.trace.jsonl + line 1: step 0: resize 40x10 + the changed nodes differ from the recording + expected: {"added":["root","root/index(0)","root/index(0)/index(0)","root/index(0)/index(1)","root/index(0)/index(2)","root/index(0)/index(2)/index(0)","root/index(0)/index(2)/index(1)","root/index(0)/index(3)","root/index(0)/index(3)/index(0)","root/index(0)/index(3)/index(1)","root/index(0)/index(3)/index(2)","root/index(0)/index(4)","root/index(0)/index(5)"],"changed":[],"removed":[]} + actual: {"added":["root","root/index(0)","root/index(0)/index(0)","root/index(0)/index(1)","root/index(0)/index(2)"],"changed":[],"removed":[]} + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| + 8| + 9| footer + line 2: step 1: key R + the changed rows differ from the recording + expected: [2] + actual: [] + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| + 8| + 9| footer + line 3: step 2: key o + the changed nodes differ from the recording + expected: {"added":[],"changed":["root/index(0)/index(2)","root/index(0)/index(2)/index(1)"],"removed":[]} + actual: {"added":[],"changed":[],"removed":[]} + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| + 8| + 9| footer + line 4: step 3: key b + the changed nodes differ from the recording + expected: {"added":[],"changed":["root/index(0)/index(2)","root/index(0)/index(2)/index(1)"],"removed":[]} + actual: {"added":[],"changed":[],"removed":[]} + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| + 8| + 9| footer + line 5: step 4: resize 30x8 + the changed nodes differ from the recording + expected: {"added":[],"changed":["root","root/index(0)","root/index(0)/index(0)","root/index(0)/index(1)","root/index(0)/index(2)","root/index(0)/index(3)","root/index(0)/index(3)/index(1)","root/index(0)/index(3)/index(2)","root/index(0)/index(4)","root/index(0)/index(5)"],"removed":[]} + actual: {"added":[],"changed":["root","root/index(0)","root/index(0)/index(0)","root/index(0)/index(1)","root/index(0)/index(2)"],"removed":[]} + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| footer + line 6: step 5: key Backspace + the changed nodes differ from the recording + expected: {"added":[],"changed":["root/index(0)/index(2)","root/index(0)/index(2)/index(1)"],"removed":[]} + actual: {"added":[],"changed":[],"removed":[]} + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| footer + + """, + stderr: "" + ) + """# + } } @Test @@ -580,17 +1040,64 @@ struct HarnessCLIScenarioTests { let notRecords = await run("replay", notTrace, "--app", "stack", "--pretty") let unknown = await run("replay", "run.trace", "--app", "ghost", "--pretty") - #expect(noFile.stderr == "error: usage: replay FILE --app NAME\n") - #expect(noName.stderr == "error: usage: replay FILE --app NAME\n") - #expect(missing.exitCode == 2) - #expect(missing.stderr.contains("cannot read the trace")) - #expect(notUTF8.exitCode == 2) - #expect(notUTF8.stderr.contains("is not UTF-8 text")) - #expect(notRecords.exitCode == 2) - #expect( - notRecords.stderr.hasPrefix("error: ") || notRecords.stdout.contains("error:")) - #expect(unknown.exitCode == 2) - #expect(unknown.stderr.hasPrefix("error: unknown app `ghost`; accepted: ")) + assertInlineSnapshot( + of: scratch.dump([noFile, noName, missing, notUTF8, notRecords, unknown]), + as: .lines + ) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: usage: replay FILE --app NAME + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: usage: replay FILE --app NAME + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: cannot read the trace `/none.jsonl` + + """ + ), + [3]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: the trace `/bin.jsonl` is not UTF-8 text + + """ + ), + [4]: CommandOutput( + exitCode: 2, + stdout: """ + FAIL /nope.jsonl + /nope.jsonl:1: error: this line is not a trace record (not valid JSON) + + """, + stderr: "" + ), + [5]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown app `ghost`; accepted: counter, event-log, greedy, main-actor-counter, observed-counter, stack, workspace + + """ + ) + ] + """# + } } @Test @@ -604,9 +1111,18 @@ struct HarnessCLIScenarioTests { inputIsTerminal: true, outputIsTerminal: true, configuration: .default, environment: ["TESSERA_TRACE": scratch.path]) - #expect(output.exitCode == 3) - #expect( - output.stderr.hasPrefix("error: cannot create the trace file `\(scratch.path)`")) + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: cannot create the trace file ``: the file could not be created + + """ + ) + """# + } } // MARK: help diff --git a/Tests/TesseraHarnessTests/ProjectionFixtures.swift b/Tests/TesseraHarnessTests/ProjectionFixtures.swift index 85bbc5c..a8b0c87 100644 --- a/Tests/TesseraHarnessTests/ProjectionFixtures.swift +++ b/Tests/TesseraHarnessTests/ProjectionFixtures.swift @@ -6,8 +6,8 @@ import TesseraTerminalInput @testable import TesseraHarness -/// A custom layout that puts every child at the origin: the defect plan 034 fixed in `ForEach`, -/// written by hand so a check has something to find. +/// A custom layout that puts every child at the origin, the defect of a `ForEach` whose rows +/// overlap, written by hand so a check has something to find. private struct Overlap: Layout { func sizeThatFits(_ proposal: ProposedSize, subviews: Subviews) -> TerminalSize { let sizes = subviews.map { $0.sizeThatFits(proposal) } @@ -92,7 +92,7 @@ final class WideApp: Application { /// A counter that is clean after every step it ignores: it returns its root explicitly, so /// reconciliation skips the whole tree when nothing changed. -private struct Stable: View, Equatable { +private struct Stable: Equatable, View { var count: Int var body: some View { diff --git a/Tests/TesseraHarnessTests/ProjectionTests.swift b/Tests/TesseraHarnessTests/ProjectionTests.swift index b542ebc..2e3bae2 100644 --- a/Tests/TesseraHarnessTests/ProjectionTests.swift +++ b/Tests/TesseraHarnessTests/ProjectionTests.swift @@ -1,4 +1,8 @@ +import CustomDump import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump import TesseraCore import TesseraTerminalCore import TesseraTerminalInput @@ -42,14 +46,79 @@ struct ProjectionTests { { let projection = try await project(of: "layers") - let types = projection.nodes.map(\.type) - #expect(types.contains("VStack")) - #expect(types.contains("ZStack")) - #expect(types.contains("ForEach")) - #expect(types.filter { $0 == "_LayoutContainer" }.count == 2) - #expect( - projection.nodes.filter { $0.type == "_LayoutContainer" }.compactMap(\.layout) - == ["_FrameLayout", "_PaddingLayout"]) + assertInlineSnapshot( + of: projection.nodes.map { ($0.path, $0.type, $0.layout) }, as: .customDump + ) { + """ + [ + [0]: ( + "root", + "TupleView", + nil + ), + [1]: ( + "root/index(0)", + "VStack", + nil + ), + [2]: ( + "root/index(0)/index(0)", + "ZStack", + nil + ), + [3]: ( + "root/index(0)/index(0)/index(0)", + "Text", + nil + ), + [4]: ( + "root/index(0)/index(0)/index(1)", + "_LayoutContainer", + "_FrameLayout" + ), + [5]: ( + "root/index(0)/index(0)/index(1)/index(0)", + "Text", + nil + ), + [6]: ( + "root/index(0)/index(1)", + "_LayoutContainer", + "_PaddingLayout" + ), + [7]: ( + "root/index(0)/index(1)/index(0)", + "Text", + nil + ), + [8]: ( + "root/index(0)/index(2)", + "ForEach", + nil + ), + [9]: ( + "root/index(0)/index(2)/id(a)", + "TupleView", + nil + ), + [10]: ( + "root/index(0)/index(2)/id(a)/index(0)", + "Text", + nil + ), + [11]: ( + "root/index(0)/index(2)/id(b)", + "TupleView", + nil + ), + [12]: ( + "root/index(0)/index(2)/id(b)/index(0)", + "Text", + nil + ) + ] + """ + } #expect( projection.nodes.filter { $0.type != "_LayoutContainer" }.allSatisfy { $0.layout == nil @@ -65,33 +134,424 @@ struct ProjectionTests { { let projection = try await project(of: "layers") - let root = try #require(projection.nodes.first) - #expect(root.path == "root") - #expect(root.depth == 0) - #expect(root.parent == nil) - let stack = try node(projection, "root/index(0)") - #expect(stack.depth == 1) - #expect(stack.children.count == 3) - let positions = stack.children.compactMap { projection.node(at: $0)?.paintOrder } - #expect(positions == [0, 1, 2]) - let items = try node(projection, "root/index(0)/index(2)") - #expect(items.type == "ForEach") - #expect( - items.children == ["root/index(0)/index(2)/id(a)", "root/index(0)/index(2)/id(b)"]) - #expect( - items.children.compactMap { projection.node(at: $0)?.paintOrder } == [0, 1]) + assertInlineSnapshot( + of: projection.nodes.map { + ($0.path, $0.parent, $0.depth, $0.paintOrder, $0.children) + }, + as: .customDump + ) { + """ + [ + [0]: ( + "root", + nil, + 0, + 0, + [ + [0]: "root/index(0)" + ] + ), + [1]: ( + "root/index(0)", + "root", + 1, + 0, + [ + [0]: "root/index(0)/index(0)", + [1]: "root/index(0)/index(1)", + [2]: "root/index(0)/index(2)" + ] + ), + [2]: ( + "root/index(0)/index(0)", + "root/index(0)", + 2, + 0, + [ + [0]: "root/index(0)/index(0)/index(0)", + [1]: "root/index(0)/index(0)/index(1)" + ] + ), + [3]: ( + "root/index(0)/index(0)/index(0)", + "root/index(0)/index(0)", + 3, + 0, + [] + ), + [4]: ( + "root/index(0)/index(0)/index(1)", + "root/index(0)/index(0)", + 3, + 1, + [ + [0]: "root/index(0)/index(0)/index(1)/index(0)" + ] + ), + [5]: ( + "root/index(0)/index(0)/index(1)/index(0)", + "root/index(0)/index(0)/index(1)", + 4, + 0, + [] + ), + [6]: ( + "root/index(0)/index(1)", + "root/index(0)", + 2, + 1, + [ + [0]: "root/index(0)/index(1)/index(0)" + ] + ), + [7]: ( + "root/index(0)/index(1)/index(0)", + "root/index(0)/index(1)", + 3, + 0, + [] + ), + [8]: ( + "root/index(0)/index(2)", + "root/index(0)", + 2, + 2, + [ + [0]: "root/index(0)/index(2)/id(a)", + [1]: "root/index(0)/index(2)/id(b)" + ] + ), + [9]: ( + "root/index(0)/index(2)/id(a)", + "root/index(0)/index(2)", + 3, + 0, + [ + [0]: "root/index(0)/index(2)/id(a)/index(0)" + ] + ), + [10]: ( + "root/index(0)/index(2)/id(a)/index(0)", + "root/index(0)/index(2)/id(a)", + 4, + 0, + [] + ), + [11]: ( + "root/index(0)/index(2)/id(b)", + "root/index(0)/index(2)", + 3, + 1, + [ + [0]: "root/index(0)/index(2)/id(b)/index(0)" + ] + ), + [12]: ( + "root/index(0)/index(2)/id(b)/index(0)", + "root/index(0)/index(2)/id(b)", + 4, + 0, + [] + ) + ] + """ + } } @Test func `frames, clips, proposals, and measured sizes come from the graph`() async throws { let projection = try await project(of: "layers") - let frame = try #require(projection.nodes.first { $0.layout == "_FrameLayout" }) - #expect(frame.frame == Projection.Box(column: 0, row: 0, columns: 2, rows: 1)) - let padding = try #require(projection.nodes.first { $0.layout == "_PaddingLayout" }) - #expect(padding.frame == Projection.Box(column: 0, row: 1, columns: 4, rows: 3)) - #expect(padding.measured == Projection.Extent(columns: 4, rows: 3)) - #expect(padding.proposal?.width == 40) + assertInlineSnapshot( + of: projection.nodes.map { ($0.path, $0.frame, $0.clip, $0.proposal, $0.measured) }, + as: .customDump + ) { + """ + [ + [0]: ( + "root", + Projection.Box( + column: 0, + row: 0, + columns: 40, + rows: 12 + ), + Projection.Box( + column: 0, + row: 0, + columns: 40, + rows: 12 + ), + Projection.Proposal( + width: 40, + height: 12 + ), + Projection.Extent( + columns: 6, + rows: 6 + ) + ), + [1]: ( + "root/index(0)", + Projection.Box( + column: 0, + row: 0, + columns: 40, + rows: 12 + ), + Projection.Box( + column: 0, + row: 0, + columns: 40, + rows: 12 + ), + Projection.Proposal( + width: 40, + height: 12 + ), + Projection.Extent( + columns: 6, + rows: 6 + ) + ), + [2]: ( + "root/index(0)/index(0)", + Projection.Box( + column: 0, + row: 0, + columns: 6, + rows: 1 + ), + Projection.Box( + column: 0, + row: 0, + columns: 6, + rows: 1 + ), + Projection.Proposal( + width: 40, + height: 1 + ), + Projection.Extent( + columns: 6, + rows: 1 + ) + ), + [3]: ( + "root/index(0)/index(0)/index(0)", + Projection.Box( + column: 0, + row: 0, + columns: 6, + rows: 1 + ), + Projection.Box( + column: 0, + row: 0, + columns: 6, + rows: 1 + ), + Projection.Proposal( + width: 6, + height: 1 + ), + Projection.Extent( + columns: 6, + rows: 1 + ) + ), + [4]: ( + "root/index(0)/index(0)/index(1)", + Projection.Box( + column: 0, + row: 0, + columns: 2, + rows: 1 + ), + Projection.Box( + column: 0, + row: 0, + columns: 2, + rows: 1 + ), + Projection.Proposal( + width: 6, + height: 1 + ), + Projection.Extent( + columns: 2, + rows: 1 + ) + ), + [5]: ( + "root/index(0)/index(0)/index(1)/index(0)", + Projection.Box( + column: 0, + row: 0, + columns: 2, + rows: 1 + ), + Projection.Box( + column: 0, + row: 0, + columns: 2, + rows: 1 + ), + Projection.Proposal( + width: 2, + height: 1 + ), + Projection.Extent( + columns: 2, + rows: 1 + ) + ), + [6]: ( + "root/index(0)/index(1)", + Projection.Box( + column: 0, + row: 1, + columns: 4, + rows: 3 + ), + Projection.Box( + column: 0, + row: 1, + columns: 4, + rows: 3 + ), + Projection.Proposal( + width: 40, + height: 3 + ), + Projection.Extent( + columns: 4, + rows: 3 + ) + ), + [7]: ( + "root/index(0)/index(1)/index(0)", + Projection.Box( + column: 1, + row: 2, + columns: 2, + rows: 1 + ), + Projection.Box( + column: 1, + row: 2, + columns: 2, + rows: 1 + ), + Projection.Proposal( + width: 2, + height: 1 + ), + Projection.Extent( + columns: 2, + rows: 1 + ) + ), + [8]: ( + "root/index(0)/index(2)", + Projection.Box( + column: 0, + row: 4, + columns: 1, + rows: 2 + ), + Projection.Box( + column: 0, + row: 4, + columns: 1, + rows: 2 + ), + nil, + nil + ), + [9]: ( + "root/index(0)/index(2)/id(a)", + Projection.Box( + column: 0, + row: 4, + columns: 1, + rows: 1 + ), + Projection.Box( + column: 0, + row: 4, + columns: 1, + rows: 1 + ), + nil, + nil + ), + [10]: ( + "root/index(0)/index(2)/id(a)/index(0)", + Projection.Box( + column: 0, + row: 4, + columns: 1, + rows: 1 + ), + Projection.Box( + column: 0, + row: 4, + columns: 1, + rows: 1 + ), + Projection.Proposal( + width: 40, + height: 1 + ), + Projection.Extent( + columns: 1, + rows: 1 + ) + ), + [11]: ( + "root/index(0)/index(2)/id(b)", + Projection.Box( + column: 0, + row: 5, + columns: 1, + rows: 1 + ), + Projection.Box( + column: 0, + row: 5, + columns: 1, + rows: 1 + ), + nil, + nil + ), + [12]: ( + "root/index(0)/index(2)/id(b)/index(0)", + Projection.Box( + column: 0, + row: 5, + columns: 1, + rows: 1 + ), + Projection.Box( + column: 0, + row: 5, + columns: 1, + rows: 1 + ), + Projection.Proposal( + width: 40, + height: 1 + ), + Projection.Extent( + columns: 1, + rows: 1 + ) + ) + ] + """ + } #expect(projection.nodes.allSatisfy { !$0.needsLayout && !$0.needsRender }) } @@ -103,14 +563,65 @@ struct ProjectionTests { { let projection = try await project(of: "layers") - let status = try #require( - projection.nodes.first { $0.text?.contains("atus") == true }) - #expect(status.text == " atus") - #expect(projection.nodes.contains { $0.text == "OK" }) - #expect(projection.nodes.contains { $0.text?.contains("status") == true } == false) - let stack = try node(projection, "root/index(0)") - #expect( - stack.text == nil, "a container shows its leaves' text, and has none of its own") + assertInlineSnapshot(of: projection.nodes.map { ($0.path, $0.text) }, as: .customDump) + { + """ + [ + [0]: ( + "root", + nil + ), + [1]: ( + "root/index(0)", + nil + ), + [2]: ( + "root/index(0)/index(0)", + nil + ), + [3]: ( + "root/index(0)/index(0)/index(0)", + " atus" + ), + [4]: ( + "root/index(0)/index(0)/index(1)", + nil + ), + [5]: ( + "root/index(0)/index(0)/index(1)/index(0)", + "OK" + ), + [6]: ( + "root/index(0)/index(1)", + nil + ), + [7]: ( + "root/index(0)/index(1)/index(0)", + "Go" + ), + [8]: ( + "root/index(0)/index(2)", + nil + ), + [9]: ( + "root/index(0)/index(2)/id(a)", + nil + ), + [10]: ( + "root/index(0)/index(2)/id(a)/index(0)", + "a" + ), + [11]: ( + "root/index(0)/index(2)/id(b)", + nil + ), + [12]: ( + "root/index(0)/index(2)/id(b)/index(0)", + "b" + ) + ] + """ + } } @Test @@ -120,7 +631,16 @@ struct ProjectionTests { let projection = try await project(of: "wide") let wide = try #require(projection.nodes.first { $0.text == "東京 map" }) - #expect(wide.frame == Projection.Box(column: 0, row: 0, columns: 8, rows: 1)) + assertInlineSnapshot(of: wide.frame, as: .customDump) { + """ + Projection.Box( + column: 0, + row: 0, + columns: 8, + rows: 1 + ) + """ + } #expect(projection.screen.rows.first == "東京 map") } @@ -131,15 +651,45 @@ struct ProjectionTests { let projection = try await project( of: "styled", terminal: TerminalProfile(colors: .truecolor)) - #expect(Array(projection.screen.rows.prefix(3)) == ["bold", "plain", "blue"]) - #expect(projection.screen.columns == 40) - #expect( - projection.screen.styles == [ - Projection.Screen.StyleRun( - row: 0, column: 0, columns: 4, style: "fg=#ff0000 bold"), - Projection.Screen.StyleRun( - row: 2, column: 0, columns: 4, style: "bg=indexed(4) underline"), - ]) + assertInlineSnapshot(of: projection.screen, as: .customDump) { + """ + Projection.Screen( + columns: 40, + cursor: Projection.Screen.Cursor( + column: 39, + row: 11 + ), + rows: [ + [0]: "bold", + [1]: "plain", + [2]: "blue", + [3]: "", + [4]: "", + [5]: "", + [6]: "", + [7]: "", + [8]: "", + [9]: "", + [10]: "", + [11]: "" + ], + styles: [ + [0]: Projection.Screen.StyleRun( + row: 0, + column: 0, + columns: 4, + style: "fg=#ff0000 bold" + ), + [1]: Projection.Screen.StyleRun( + row: 2, + column: 0, + columns: 4, + style: "bg=indexed(4) underline" + ) + ] + ) + """ + } } @Test @@ -147,10 +697,31 @@ struct ProjectionTests { { let projection = try await project(of: "layers") - #expect(projection.statistics.invalidationReasons.contains("renderRequested")) - #expect(projection.statistics.renderedNodes > 0) - #expect(projection.statistics.measurements > 0) - #expect(projection.statistics.placements > 0) + assertInlineSnapshot(of: projection.statistics, as: .customDump) { + """ + Projection.Statistics( + bodyEvaluations: 0, + equatableSkips: 0, + focusChanges: 0, + invalidationReasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + leafUpdates: 5, + measurements: 29, + nodesCreated: 0, + nodesDestroyed: 0, + nodesUpdated: 13, + placements: 13, + renderedNodes: 5, + terminalRequirementChanges: 0 + ) + """ + } let json = try #require( String(bytes: try JSONEncoder().encode(projection), encoding: .utf8)) #expect(!json.lowercased().contains("duration")) @@ -202,9 +773,10 @@ struct ProjectionTests { let json = try #require(String(bytes: try encoder.encode(root), encoding: .utf8)) - #expect(!json.contains("\"parent\"")) - #expect(!json.contains("\"layout\"")) - #expect(!json.contains("\"text\"")) - #expect(json.contains("\"path\":\"root\"")) + assertInlineSnapshot(of: json, as: .lines) { + #""" + {"children":["root\/index(0)"],"clip":{"column":0,"columns":40,"row":0,"rows":12},"depth":0,"environmentOverrides":[],"frame":{"column":0,"columns":40,"row":0,"rows":12},"handlerKinds":[],"isList":true,"measured":{"columns":6,"rows":6},"needsLayout":false,"needsRender":false,"paintOrder":0,"path":"root","proposal":{"height":12,"width":40},"requirements":[],"type":"TupleView"} + """# + } } } diff --git a/Tests/TesseraHarnessTests/ReplayTests.swift b/Tests/TesseraHarnessTests/ReplayTests.swift index aee26a6..60dacfe 100644 --- a/Tests/TesseraHarnessTests/ReplayTests.swift +++ b/Tests/TesseraHarnessTests/ReplayTests.swift @@ -1,4 +1,8 @@ +import CustomDump import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump import TesseraRuntime import TesseraTerminalCore import TesseraTerminalInput @@ -61,10 +65,24 @@ struct ReplayTests { let report = await replay(records, app: "growing") - #expect(report.passed, "\(report)") - #expect(report.observations.map(\.verb) == ["replay"]) - #expect(report.observations.first?.lines == ["a", "b"]) - #expect(report.trace.map(\.seq) == [0, 1, 2, 3]) + expectNoDifference(report.failures, []) + assertInlineSnapshot(of: report.observations, as: .customDump) { + """ + [ + [0]: ScenarioObservation( + line: 0, + verb: "replay", + name: nil, + lines: [ + [0]: "a", + [1]: "b" + ], + projection: nil + ) + ] + """ + } + expectNoDifference(report.trace.map(\.seq), [0, 1, 2, 3]) } @Test @@ -77,14 +95,38 @@ struct ReplayTests { let report = await replay(records, app: "growing", in: other) - #expect(!report.passed) - let first = try #require(report.failures.first) - #expect(first.verb == "replay") - #expect(first.statement.hasPrefix("step 0: resize 40x12")) - #expect(first.line == 1) - #expect(first.message.hasSuffix("differ from the recording")) - #expect(first.expected != first.actual) - #expect(report.failures.count == 2, "the replay goes on and reports step 1 too") + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 1, + statement: "step 0: resize 40x12", + verb: "replay", + message: "the reasons differ from the recording", + expected: "layoutEnvironmentChanged, layoutViewChanged, renderEnvironmentChanged, renderRequested, renderViewChanged, updateRequested", + actual: "renderRequested, updateRequested", + screen: [ + [0]: "count 0" + ], + rule: nil, + node: nil + ), + [1]: ScenarioFailure( + line: 2, + statement: "step 1: key +", + verb: "replay", + message: "the reasons differ from the recording", + expected: "layoutEnvironmentChanged, layoutViewChanged, renderEnvironmentChanged, renderRequested, renderViewChanged, updateRequested", + actual: "layoutViewChanged, renderRequested, renderViewChanged, updateRequested", + screen: [ + [0]: "count 1" + ], + rule: nil, + node: nil + ) + ] + """ + } } @Test @@ -98,9 +140,26 @@ struct ReplayTests { records[1].cost.measures += 1 let measured = await replay(records, app: "steady") - #expect(bytesOnly.passed, "\(bytesOnly)") - #expect(measured.failures.map(\.message) == ["the costs differ from the recording"]) - #expect(measured.failures.first?.statement == "step 1: key +") + expectNoDifference(bytesOnly.failures, []) + assertInlineSnapshot(of: measured.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 2, + statement: "step 1: key +", + verb: "replay", + message: "the costs differ from the recording", + expected: "bodies=1 bytes=116 measures=5 placements=4 renders=1", + actual: "bodies=1 bytes=17 measures=4 placements=4 renders=1", + screen: [ + [0]: "count 1" + ], + rule: nil, + node: nil + ) + ] + """ + } } @Test @@ -118,14 +177,60 @@ struct ReplayTests { let rowReport = await replay(rows, app: "steady") let drawReport = await replay(drawing, app: "steady") - #expect( - reasonReport.failures.map(\.message) == ["the reasons differ from the recording"]) - #expect( - rowReport.failures.map(\.message) == ["the changed rows differ from the recording"]) - #expect( - drawReport.failures.map(\.message) == [ - "the recording drew a frame and the replay drew none" - ]) + assertInlineSnapshot( + of: [reasonReport.failures, rowReport.failures, drawReport.failures], + as: .customDump + ) { + """ + [ + [0]: [ + [0]: ScenarioFailure( + line: 2, + statement: "step 1: key +", + verb: "replay", + message: "the reasons differ from the recording", + expected: "layoutViewChanged, renderRequested, renderViewChanged, updateRequested, layoutViewportChanged", + actual: "layoutViewChanged, renderRequested, renderViewChanged, updateRequested", + screen: [ + [0]: "count 1" + ], + rule: nil, + node: nil + ) + ], + [1]: [ + [0]: ScenarioFailure( + line: 2, + statement: "step 1: key +", + verb: "replay", + message: "the changed rows differ from the recording", + expected: "[3]", + actual: "[0]", + screen: [ + [0]: "count 1" + ], + rule: nil, + node: nil + ) + ], + [2]: [ + [0]: ScenarioFailure( + line: 3, + statement: "step 2: key x", + verb: "replay", + message: "the recording drew a frame and the replay drew none", + expected: "drew true", + actual: "drew false", + screen: [ + [0]: "count 1" + ], + rule: nil, + node: nil + ) + ] + ] + """ + } } @Test @@ -137,9 +242,21 @@ struct ReplayTests { let report = await replay(records, app: "growing", in: finished) - let last = try #require(report.failures.last) - #expect(last.verb == "replay") - #expect(last.message.hasPrefix("the application finished before step 1")) + assertInlineSnapshot(of: report.failures.last, as: .customDump) { + """ + ScenarioFailure( + line: 2, + statement: "step 1: key +", + verb: "replay", + message: "the application finished before step 1, where the recording kept going", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + """ + } } // MARK: What a replay refuses @@ -156,12 +273,30 @@ struct ReplayTests { let report = await replay(records, app: "steady") - #expect(report.failures.count == 1) - let failure = try #require(report.failures.first) - #expect(failure.verb == "usage") - #expect(failure.line == 2) - #expect(failure.message.contains("unknown")) - #expect(report.observations.isEmpty, "nothing ran") + assertInlineSnapshot(of: report, as: .customDump) { + """ + ScenarioReport( + scenario: "run.trace.jsonl", + failures: [ + [0]: ScenarioFailure( + line: 2, + statement: "step 1", + verb: "usage", + message: "step 1 holds an event replay cannot deliver: unknown (not replayable); the scenario grammar has no statement for it yet", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ], + observations: [], + recorded: [], + seconds: [], + trace: [] + ) + """ + } } @Test @@ -171,15 +306,15 @@ struct ReplayTests { let records = try await record( "growing", [.focusGained, key("+"), .focusLost, key("+"), .focusGained]) - #expect( - records.map(\.event.type) == [ - "resize", "focusGained", "key", "focusLost", "key", "focusGained", - ]) + expectNoDifference( + records.map(\.event.type), + ["resize", "focusGained", "key", "focusLost", "key", "focusGained"] + ) let report = await replay(records, app: "growing") - #expect(report.passed, "\(report)") - #expect(report.trace.map(\.event) == records.map(\.event)) - #expect(report.observations.first?.lines == ["a", "b", "c"]) + expectNoDifference(report.failures, []) + expectNoDifference(report.trace.map(\.event), records.map(\.event)) + expectNoDifference(report.observations.first?.lines, ["a", "b", "c"]) } @Test @@ -195,11 +330,53 @@ struct ReplayTests { let empty = await Replayer.replay( text: "\n", file: "empty.jsonl", app: "steady", registry: registry) - #expect(withoutStart.failures.map(\.verb) == ["usage"]) - #expect(withoutStart.failures.first?.message.contains("must be step 0") == true) - #expect(skippedStep.failures.map(\.verb) == ["usage"]) - #expect(skippedStep.failures.first?.line == 2) - #expect(empty.failures.first?.message == "the trace holds no steps") + assertInlineSnapshot( + of: [withoutStart.failures, skippedStep.failures, empty.failures], as: .customDump + ) { + """ + [ + [0]: [ + [0]: ScenarioFailure( + line: 1, + statement: "", + verb: "usage", + message: "the first record must be step 0, the resize that tells the application its starting size; it is step 1, key +", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ], + [1]: [ + [0]: ScenarioFailure( + line: 2, + statement: "", + verb: "usage", + message: "the steps are numbered 0, 1, 2, …; this one is 5", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ], + [2]: [ + [0]: ScenarioFailure( + line: 0, + statement: "", + verb: "usage", + message: "the trace holds no steps", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ] + ] + """ + } } @Test @@ -207,9 +384,29 @@ struct ReplayTests { let report = await Replayer.replay( text: "{\"seq\": 0}\n", file: "bad.jsonl", app: "steady", registry: registry) - #expect(report.failures.map(\.verb) == ["usage"]) - #expect(report.failures.first?.line == 1) - #expect(report.description.hasPrefix("FAIL bad.jsonl")) + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 1, + statement: "", + verb: "usage", + message: "this line is not a trace record (no `version`)", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ] + """ + } + assertInlineSnapshot(of: report.description, as: .lines) { + """ + FAIL bad.jsonl + bad.jsonl:1: error: this line is not a trace record (no `version`) + """ + } } @Test @@ -218,7 +415,22 @@ struct ReplayTests { let report = await replay(records, app: "ghost") - #expect(report.failures.map(\.verb) == ["harness"]) - #expect(report.failures.first?.message.contains("unknown app `ghost`") == true) + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 0, + statement: "", + verb: "harness", + message: "unknown app `ghost`; accepted: growing, layers, list-layers, list-overlap, overflow, overlap, shadowed-list, steady, styled, wide", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ] + """ + } } } diff --git a/Tests/TesseraHarnessTests/ScenarioRunnerTests.swift b/Tests/TesseraHarnessTests/ScenarioRunnerTests.swift index 9862b27..d08f115 100644 --- a/Tests/TesseraHarnessTests/ScenarioRunnerTests.swift +++ b/Tests/TesseraHarnessTests/ScenarioRunnerTests.swift @@ -1,4 +1,8 @@ +import CustomDump import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump import TesseraCore import TesseraRuntime import TesseraTerminalInput @@ -67,11 +71,13 @@ struct ScenarioRunnerFileTests { let reports = await runner().run(scratch.path) - #expect( - reports.map(\.scenario) == [ + expectNoDifference( + reports.map(\.scenario), + [ scratch.appending("a.scn"), scratch.appending("b.scn"), scratch.appending("nested/a.scn"), scratch.appending("nested/deep/c.scn"), - ]) + ] + ) } @Test @@ -83,7 +89,7 @@ struct ScenarioRunnerFileTests { let reports = await runner().run(scratch.path + "/") - #expect(reports.map(\.scenario) == [scratch.appending("a.scn")]) + expectNoDifference(reports.map(\.scenario), [scratch.appending("a.scn")]) } @Test @@ -98,12 +104,136 @@ struct ScenarioRunnerFileTests { 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)")) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/missing.scn", + failures: [ + [0]: ScenarioFailure( + line: 0, + statement: "", + verb: "usage", + message: "no such file or directory", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ], + observations: [], + recorded: [], + seconds: [], + trace: [] + ), + [1]: ScenarioReport( + scenario: "/plain.txt", + failures: [ + [0]: ScenarioFailure( + line: 0, + statement: "", + verb: "usage", + message: "scenario files end in `.scn`", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ], + observations: [], + recorded: [], + seconds: [], + trace: [] + ), + [2]: ScenarioReport( + scenario: "/empty", + failures: [ + [0]: ScenarioFailure( + line: 0, + statement: "", + verb: "usage", + message: "no scenario files (*.scn) under this directory", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ], + observations: [], + recorded: [], + seconds: [], + trace: [] + ), + [3]: ScenarioReport( + scenario: "/ok.scn", + failures: [], + observations: [], + recorded: [], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] + ) + ] + """ + } } @Test @@ -122,11 +252,84 @@ struct ScenarioRunnerFileTests { 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", - ]) + assertInlineSnapshot(of: reports[0].failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 6, + statement: #"expect text 0 "Stack demos""#, + verb: "expect text", + message: "row 0 text differs", + expected: "Stack demos", + actual: "Stack demo", + screen: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: -", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + rule: nil, + node: nil + ), + [1]: ScenarioFailure( + line: 7, + statement: #"expect text "no such text""#, + verb: "expect text", + message: "no screen row contains the text", + expected: "no such text", + actual: "(not found in 10 rows)", + screen: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: -", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + rule: nil, + node: nil + ), + [2]: ScenarioFailure( + line: 8, + statement: "expect node id=root frame 0,0,40x6", + verb: "expect node", + message: "frame of `id=root` differs", + expected: "0,0,40x6", + actual: "0,0,40x10", + screen: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: -", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + rule: nil, + node: nil + ), + [3]: ScenarioFailure( + line: 10, + statement: "expect node type=Missing frame 0,0,1x1", + verb: "expect node", + message: "locator `type=Missing` matches no node", + expected: "0,0,1x1", + actual: "(no node; types: TupleView, VStack, Text, Divider, HStack, Spacer)", + screen: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: -", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + rule: nil, + node: nil + ) + ] + """ + } } // MARK: Parse errors @@ -136,25 +339,112 @@ struct ScenarioRunnerFileTests { async throws { let scratch = try ScratchDirectory() - let bad = try scratch.write("a-bad.scn", "app stack\nsize 40x10\nblink 3\n") + 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""#)) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/a-bad.scn", + failures: [ + [0]: ScenarioFailure( + line: 3, + statement: "blink 3", + verb: "parse", + message: "unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ], + observations: [], + recorded: [], + seconds: [], + trace: [] + ), + [1]: ScenarioReport( + scenario: "/b-good.scn", + failures: [], + observations: [], + recorded: [], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] + ) + ] + """ + } + assertInlineSnapshot(of: scratch.redacted(reports[0].description), as: .lines) { + """ + FAIL /a-bad.scn + /a-bad.scn:3: error: unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + """ + } + assertInlineSnapshot(of: scratch.redacted(reports[0].json), as: .lines) { + """ + {"failures":[{"line":3,"message":"unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost","statement":"blink 3","verb":"parse"}],"observations":[],"passed":false,"scenario":"/a-bad.scn"} + """ + } } @Test @@ -180,10 +470,32 @@ struct ScenarioRunnerFileTests { 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) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/a.scn", + failures: [ + [0]: ScenarioFailure( + line: 0, + statement: "", + verb: "harness", + message: "applications registered more than once: `stack`", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ], + observations: [], + recorded: [], + seconds: [], + trace: [] + ) + ] + """ + } } // MARK: Goldens @@ -197,12 +509,217 @@ struct ScenarioRunnerFileTests { 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) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [ + [0]: ScenarioFailure( + line: 5, + statement: "shot after-typing", + verb: "shot", + message: "shot `after-typing` has no golden at `/stack/after-typing.txt`; run with --record to create it, and review the file", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + projection: nil + ) + ], + recorded: [], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] + ) + ] + """ + } #expect(scratch.read("stack/after-typing.txt") == nil) } @@ -215,41 +732,439 @@ struct ScenarioRunnerFileTests { 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), - GoldenChange( - path: scratch.appending("stack/after-typing.grid.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 - - """) - #expect( - scratch.read("stack/after-typing.grid.txt") - == """ - ── chars ── - Stack demo - ──────────────────────────────────────── - Name: Rob - left right - ──────────────────────────────────────── - 40 columns - ── style ── - ── legend ── - . default - - """) + assertInlineSnapshot(of: scratch.dump(recorded), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + projection: nil + ) + ], + recorded: [ + [0]: GoldenChange( + path: "/stack/after-typing.txt", + change: .created + ), + [1]: GoldenChange( + path: "/stack/after-typing.grid.txt", + change: .created + ) + ], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] + ) + ] + """ + } + assertInlineSnapshot(of: scratch.dump(checked), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + projection: nil + ) + ], + recorded: [], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] + ) + ] + """ + } + assertInlineSnapshot(of: scratch.read("stack/after-typing.txt"), as: .lines) { + """ + Stack demo + ──────────────────────────────────────── + Name: Rob + left right + ──────────────────────────────────────── + 40 columns + + """ + } + assertInlineSnapshot(of: scratch.read("stack/after-typing.grid.txt"), as: .lines) { + """ + ── chars ── + Stack demo + ──────────────────────────────────────── + Name: Rob + left right + ──────────────────────────────────────── + 40 columns + ── style ── + ── legend ── + . default + + """ + } } @Test @@ -266,15 +1181,224 @@ struct ScenarioRunnerFileTests { 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) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + #""" + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [ + [0]: ScenarioFailure( + line: 5, + statement: "shot after-typing", + verb: "shot", + message: "shot `after-typing` differs from `/stack/after-typing.txt` at row 2: the golden has 6 rows and the screen has 6. If the new rendering is intended, rerun with --record and review the golden\'s diff", + expected: "Name: Bob", + actual: "Name: Rob", + screen: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + rule: nil, + node: nil + ) + ], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + projection: nil + ) + ], + recorded: [], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] + ) + ] + """# + } #expect(scratch.read("stack/after-typing.txt")?.contains("Name: Bob") == true) } @@ -286,15 +1410,221 @@ struct ScenarioRunnerFileTests { 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), - GoldenChange( - path: scratch.appending("stack/after-typing.grid.txt"), change: .created), - ]) - #expect(reports[0].description.contains("recorded")) - #expect(reports[0].description.contains("(updated)")) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + projection: nil + ) + ], + recorded: [ + [0]: GoldenChange( + path: "/stack/after-typing.txt", + change: .updated + ), + [1]: GoldenChange( + path: "/stack/after-typing.grid.txt", + change: .created + ) + ], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] + ) + ] + """ + } + assertInlineSnapshot(of: scratch.redacted(reports[0].description), as: .lines) { + """ + PASS /stack.scn + recorded /stack/after-typing.txt (updated) + recorded /stack/after-typing.grid.txt (created) + """ + } #expect(scratch.read("stack/after-typing.txt")?.hasPrefix("Stack demo\n") == true) } @@ -314,11 +1644,224 @@ struct ScenarioRunnerFileTests { let reports = await runner().run(scratch.path) - let failure = try #require(reports.first?.failures.first) - #expect(failure.verb == "shot") - #expect(failure.message.contains("stack/after-typing.grid.txt")) - #expect(failure.expected == ".AA") - #expect(failure.actual == "── legend ──") + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + #""" + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [ + [0]: ScenarioFailure( + line: 5, + statement: "shot after-typing", + verb: "shot", + message: "shot `after-typing` differs from `/stack/after-typing.grid.txt` at row 8: the golden has 12 rows and the screen has 10. If the new rendering is intended, rerun with --record and review the golden\'s diff", + expected: ".AA", + actual: "── legend ──", + screen: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + rule: nil, + node: nil + ) + ], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + projection: nil + ) + ], + recorded: [], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] + ) + ] + """# + } } @Test @@ -333,10 +1876,217 @@ struct ScenarioRunnerFileTests { let reports = await runner().run(scratch.path) - let failure = try #require(reports.first?.failures.first) - #expect(failure.message.contains("has no golden at")) - #expect(failure.message.contains("after-typing.grid.txt")) - #expect(failure.screen == nil) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [ + [0]: ScenarioFailure( + line: 5, + statement: "shot after-typing", + verb: "shot", + message: "shot `after-typing` has no golden at `/stack/after-typing.grid.txt`; run with --record to create it, and review the file", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + projection: nil + ) + ], + recorded: [], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] + ) + ] + """ + } } @Test @@ -357,24 +2107,31 @@ struct ScenarioRunnerFileTests { .run(scratch.path) #expect(reports.map(\.passed) == [true]) - #expect(scratch.read("styled/colours.txt") == "bold\nplain\nblue\n") - #expect( - scratch.read("styled/colours.grid.txt") - == """ - ── chars ── - bold - plain - blue - ── style ── - AAAA - - BBBB - ── legend ── - . default - A fg=#ff0000 bold - B bg=indexed(4) underline - - """) + assertInlineSnapshot(of: scratch.read("styled/colours.txt"), as: .lines) { + """ + bold + plain + blue + + """ + } + assertInlineSnapshot(of: scratch.read("styled/colours.grid.txt"), as: .lines) { + """ + ── chars ── + bold + plain + blue + ── style ── + AAAA + + BBBB + ── legend ── + . default + A fg=#ff0000 bold + B bg=indexed(4) underline + + """ + } } @Test @@ -403,9 +2160,217 @@ struct ScenarioRunnerFileTests { 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")) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [ + [0]: ScenarioFailure( + line: 5, + statement: "shot after-typing", + verb: "shot", + message: "golden `/stack/after-typing.txt` has CRLF line endings; goldens are LF files, so check them out with LF (`*.txt text eol=lf` in .gitattributes)", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + projection: nil + ) + ], + recorded: [], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] + ) + ] + """ + } } @Test @@ -424,12 +2389,217 @@ struct ScenarioRunnerFileTests { let reports = await runner().run(scratch.path) let recorded = await runner(record: true).run(scratch.path) - let failure = try #require(reports.first?.failures.first) - #expect(failure.verb == "shot") - #expect(failure.message.contains("CRLF line endings")) - #expect(failure.expected == nil) - #expect(failure.actual == nil) - #expect(failure.screen == nil) + assertInlineSnapshot(of: scratch.dump(reports), as: .lines) { + """ + [ + [0]: ScenarioReport( + scenario: "/stack.scn", + failures: [ + [0]: ScenarioFailure( + line: 5, + statement: "shot after-typing", + verb: "shot", + message: "golden `/stack/after-typing.txt` has CRLF line endings; goldens are LF files, so check them out with LF (`*.txt text eol=lf` in .gitattributes)", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ], + observations: [ + [0]: ScenarioObservation( + line: 5, + verb: "shot", + name: "after-typing", + lines: [ + [0]: "Stack demo", + [1]: "────────────────────────────────────────", + [2]: "Name: Rob", + [3]: "left right", + [4]: "────────────────────────────────────────", + [5]: "40 columns" + ], + projection: nil + ) + ], + recorded: [], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x10", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)", + [5]: "root/index(0)/index(2)/index(0)", + [6]: "root/index(0)/index(2)/index(1)", + [7]: "root/index(0)/index(3)", + [8]: "root/index(0)/index(3)/index(0)", + [9]: "root/index(0)/index(3)/index(1)", + [10]: "root/index(0)/index(3)/index(2)", + [11]: "root/index(0)/index(4)", + [12]: "root/index(0)/index(5)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1, + [2]: 2, + [3]: 3, + [4]: 4, + [5]: 5 + ], + cost: StepCost( + bodies: 0, + bytes: 639, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [1]: TraceRecord( + version: 1, + seq: 1, + event: TraceEvent( + type: "key", + key: "R", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 17, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [2]: TraceRecord( + version: 1, + seq: 2, + event: TraceEvent( + type: "key", + key: "o", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ), + [3]: TraceRecord( + version: 1, + seq: 3, + event: TraceEvent( + type: "key", + key: "b", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root/index(0)/index(2)", + [1]: "root/index(0)/index(2)/index(1)" + ], + removed: [] + ), + rows: [ + [0]: 2 + ], + cost: StepCost( + bodies: 0, + bytes: 11, + measures: 65, + placements: 13, + renders: 8 + ) + ) + ] + ) + ] + """ + } #expect(recorded.map(\.passed) == [true]) #expect(scratch.read("stack/after-typing.txt") == lf) } @@ -462,7 +2632,7 @@ struct ScenarioRunnerFileTests { let first = await runner().run(scratch.path).map(\.json) let second = await runner().run(scratch.path).map(\.json) - #expect(first == second) + expectNoDifference(first, second) #expect(first.count == 2) } @@ -492,11 +2662,34 @@ struct ScenarioRunnerFileTests { 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")) + assertInlineSnapshot(of: reports.first?.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 0, + statement: "--repeat 3", + verb: "repeat", + message: "run 2 of 3 differs from run 1: it has 1 failures where run 1 has 0", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ), + [1]: ScenarioFailure( + line: 0, + statement: "--repeat 3", + verb: "repeat", + message: "run 3 of 3 differs from run 1: it has 1 failures where run 1 has 0", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ] + """ + } } @Test @@ -509,7 +2702,20 @@ struct ScenarioRunnerFileTests { let reports = await repeating.run(scratch.path) #expect(reports.map(\.passed) == [true]) - #expect(reports[0].recorded.count == 2) + assertInlineSnapshot(of: scratch.dump(reports[0].recorded), as: .lines) { + """ + [ + [0]: GoldenChange( + path: "/stack/after-typing.txt", + change: .created + ), + [1]: GoldenChange( + path: "/stack/after-typing.grid.txt", + change: .created + ) + ] + """ + } } @Test @@ -551,40 +2757,44 @@ struct ScenarioReportShapeTests { 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"}"#) + assertInlineSnapshot(of: json, as: .lines) { + #""" + {"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") + assertInlineSnapshot(of: report.json, as: .lines) { + """ + {"failures":[],"observations":[],"passed":true,"scenario":"ok.scn"} + """ + } + assertInlineSnapshot(of: report.description, as: .lines) { + """ + 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 - """) + assertInlineSnapshot(of: failing.description, as: .lines) { + """ + 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 @@ -594,9 +2804,34 @@ struct ScenarioReportShapeTests { 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") + assertInlineSnapshot(of: parse.description, as: .lines) { + """ + FAIL p.scn + p.scn:3: error: unknown + """ + } + assertInlineSnapshot(of: usage.description, as: .lines) { + """ + FAIL dir + dir: error: no such file + """ + } + assertInlineSnapshot(of: parse.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 3, + statement: "blink", + verb: "parse", + message: "unknown", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ] + """ + } } } diff --git a/Tests/TesseraHarnessTests/ScenarioStatementsTests.swift b/Tests/TesseraHarnessTests/ScenarioStatementsTests.swift index 1606d41..9d64d7a 100644 --- a/Tests/TesseraHarnessTests/ScenarioStatementsTests.swift +++ b/Tests/TesseraHarnessTests/ScenarioStatementsTests.swift @@ -1,4 +1,8 @@ +import CustomDump import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump import Testing @testable import TesseraHarness @@ -32,38 +36,53 @@ struct NewStatementParserTests { @Test func `check with no word runs every rule and with words runs those`() throws { - #expect(try statement("check") == .check(rules: [])) - #expect( - try statement("check siblings-disjoint no-dirty-after-pass") - == .check(rules: [.siblingsDisjoint, .noDirtyAfterPass])) + expectNoDifference( + try [statement("check"), statement("check siblings-disjoint no-dirty-after-pass")], + [.check(rules: []), .check(rules: [.siblingsDisjoint, .noDirtyAfterPass])] + ) } @Test func `an unknown or repeated check rule is a parse error that names the rules`() { - #expect( - message("check no-such-rule") - == "unknown check rule `no-such-rule`; accepted: \(CheckRule.accepted)") - #expect( - message("check siblings-disjoint siblings-disjoint") - == "check rule `siblings-disjoint` is named twice") + assertInlineSnapshot( + of: [ + message("check no-such-rule"), + message("check siblings-disjoint siblings-disjoint"), + ], + as: .customDump + ) { + """ + [ + [0]: "unknown check rule `no-such-rule`; accepted: no-dirty-after-pass, frame-inside-clip, measured-within-proposal, siblings-disjoint, requirements-aggregate", + [1]: "check rule `siblings-disjoint` is named twice" + ] + """ + } } @Test func `dump takes no argument`() throws { #expect(try statement("dump") == .dump) - #expect(message("dump everything")?.hasPrefix("usage: dump") == true) + assertInlineSnapshot(of: message("dump everything"), as: .customDump) { + """ + "usage: dump (the whole projection; it takes no argument)" + """ + } } @Test func `expect cost takes a bound for each counter it names`() throws { - #expect( - try statement("expect cost bodies<=5 bytes<=40") - == .expectCost([ + expectNoDifference( + try [ + statement("expect cost bodies<=5 bytes<=40"), statement("expect cost renders<=0"), + ], + [ + .expectCost([ CostBound(counter: "bodies", limit: 5), CostBound(counter: "bytes", limit: 40), - ])) - #expect( - try statement("expect cost renders<=0") - == .expectCost([CostBound(counter: "renders", limit: 0)])) + ]), + .expectCost([CostBound(counter: "renders", limit: 0)]), + ] + ) } @Test @@ -71,57 +90,72 @@ struct NewStatementParserTests { `expect cost refuses everything but a counter, then less than or equal, then a number`() throws { - #expect(message("expect cost")?.hasPrefix("usage: expect cost NAME<=N") == true) - #expect(message("expect cost bodies<5")?.contains("only `<=` is accepted") == true) - #expect( - message("expect cost bodies <= 5")?.contains("written without spaces") == true) - #expect( - message("expect cost frames<=5") - == "unknown cost counter `frames`; accepted: bodies, bytes, measures, placements, renders" - ) - #expect( - message("expect cost bodies<=many") - == "cost bound `bodies<=many` needs a non-negative whole number after `<=`") - #expect( - message("expect cost bodies<=-1") - == "cost bound `bodies<=-1` needs a non-negative whole number after `<=`") - #expect( - message("expect cost bodies<=1 bodies<=2") - == "cost counter `bodies` is bounded twice") + let lines = [ + "expect cost", "expect cost bodies<5", "expect cost bodies <= 5", + "expect cost frames<=5", "expect cost bodies<=many", "expect cost bodies<=-1", + "expect cost bodies<=1 bodies<=2", + ] + + assertInlineSnapshot(of: lines.map(message), as: .customDump) { + #""" + [ + [0]: "usage: expect cost NAME<=N… (counters: bodies, bytes, measures, placements, renders)", + [1]: "cost bound `bodies<5` is not NAME<=N, written without spaces; only `<=` is accepted, because the counts are the library\'s work and change with it (counters: bodies, bytes, measures, placements, renders)", + [2]: "cost bound `bodies` is not NAME<=N, written without spaces; only `<=` is accepted, because the counts are the library\'s work and change with it (counters: bodies, bytes, measures, placements, renders)", + [3]: "unknown cost counter `frames`; accepted: bodies, bytes, measures, placements, renders", + [4]: "cost bound `bodies<=many` needs a non-negative whole number after `<=`", + [5]: "cost bound `bodies<=-1` needs a non-negative whole number after `<=`", + [6]: "cost counter `bodies` is bounded twice" + ] + """# + } } @Test func `the unknown statement error lists the new statements`() { - #expect( - message("blink")?.hasSuffix( - "accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, " - + "expect text, expect screen, expect node, expect cost") == true) + assertInlineSnapshot(of: message("blink"), as: .customDump) { + """ + "unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost" + """ + } } @Test func `a locator takes a layout, and the accepted keys name it`() throws { - #expect( - try statement("expect node layout=_FrameLayout nth=1 frame 0,0,2x1") - == .expectNode( - Locator(layout: "_FrameLayout", nth: 1), - FrameExpectation(column: 0, row: 0, columns: 2, rows: 1))) - #expect( - message("expect node colour=red frame 0,0,1x1") - == "unknown locator key `colour`; accepted: type, layout, id, text, nth") + expectNoDifference( + try statement("expect node layout=_FrameLayout nth=1 frame 0,0,2x1"), + .expectNode( + Locator(layout: "_FrameLayout", nth: 1), + FrameExpectation(column: 0, row: 0, columns: 2, rows: 1)) + ) + assertInlineSnapshot( + of: message("expect node colour=red frame 0,0,1x1"), as: .customDump + ) { + """ + "unknown locator key `colour`; accepted: type, layout, id, text, nth" + """ + } } @Test func `a locator key with an empty value is a parse error and not a query for everything`() { - for key in ["type", "layout", "id", "text"] { - #expect( - message("expect node \(key)= frame 0,0,1x1") - == "locator key `\(key)` needs a value that is not empty") + let lines = + ["type", "layout", "id", "text"].map { "expect node \($0)= frame 0,0,1x1" } + + [#"expect node text="" frame 0,0,1x1"#] + + assertInlineSnapshot(of: lines.map(message), as: .customDump) { + """ + [ + [0]: "locator key `type` needs a value that is not empty", + [1]: "locator key `layout` needs a value that is not empty", + [2]: "locator key `id` needs a value that is not empty", + [3]: "locator key `text` needs a value that is not empty", + [4]: "locator key `text` needs a value that is not empty" + ] + """ } - #expect( - message(#"expect node text="" frame 0,0,1x1"#) - == "locator key `text` needs a value that is not empty") } } @@ -142,14 +176,22 @@ struct NewStatementPlayerTests { func `check passes on a clean app and records the rules it ran`() async throws { let report = try await play("app layers\ncheck") - #expect(report.passed, "\(report)") - let observation = try #require(report.observations.first) - #expect(observation.verb == "check") - #expect( - observation.lines == [ - "ran no-dirty-after-pass, frame-inside-clip, measured-within-proposal, " - + "siblings-disjoint, requirements-aggregate" - ]) + expectNoDifference(report.failures, []) + assertInlineSnapshot(of: report.observations, as: .customDump) { + """ + [ + [0]: ScenarioObservation( + line: 2, + verb: "check", + name: nil, + lines: [ + [0]: "ran no-dirty-after-pass, frame-inside-clip, measured-within-proposal, siblings-disjoint, requirements-aggregate" + ], + projection: nil + ) + ] + """ + } } @Test @@ -167,8 +209,31 @@ struct NewStatementPlayerTests { check """) - #expect(report.passed, "\(report)") - #expect(report.observations.count == 2) + expectNoDifference(report.failures, []) + assertInlineSnapshot(of: report.observations, as: .customDump) { + """ + [ + [0]: ScenarioObservation( + line: 5, + verb: "check", + name: nil, + lines: [ + [0]: "ran siblings-disjoint" + ], + projection: nil + ), + [1]: ScenarioObservation( + line: 6, + verb: "check", + name: nil, + lines: [ + [0]: "ran no-dirty-after-pass, frame-inside-clip, measured-within-proposal, siblings-disjoint, requirements-aggregate" + ], + projection: nil + ) + ] + """ + } } @Test @@ -177,11 +242,25 @@ struct NewStatementPlayerTests { { let report = try await play("app list-overlap\ncheck siblings-disjoint") - let failure = try #require(report.failures.first) - #expect(report.failures.count == 1) - #expect(failure.rule == "siblings-disjoint") - #expect(failure.node?.hasSuffix("id(b)") == true) - #expect(failure.message.contains("under ForEach")) + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 2, + statement: "check siblings-disjoint", + verb: "check", + message: "siblings-disjoint at root/index(0)/index(0)/id(b): shows 0,0,1x1, which overlaps sibling root/index(0)/index(0)/id(a) showing 0,0,1x1 under ForEach", + expected: nil, + actual: nil, + screen: [ + [0]: "b" + ], + rule: "siblings-disjoint", + node: "root/index(0)/index(0)/id(b)" + ) + ] + """ + } } @Test @@ -198,28 +277,55 @@ struct NewStatementPlayerTests { // The app's `ForEach` is not looked through, so the `ZStack` does not excuse the two texts // that its container-less body puts on one frame. - let failure = try #require(report.failures.first) - #expect(report.failures.count == 1, "\(report)") - #expect(failure.verb == "check") - #expect(failure.rule == "siblings-disjoint") - #expect(failure.message.contains("under TupleView")) + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 3, + statement: "check siblings-disjoint", + verb: "check", + message: "siblings-disjoint at root/index(0)/index(0)/body/index(1): shows 0,0,6x1, which overlaps sibling root/index(0)/index(0)/body/index(0) showing 0,0,5x1 under TupleView", + expected: nil, + actual: nil, + screen: [ + [0]: "second" + ], + rule: "siblings-disjoint", + node: "root/index(0)/index(0)/body/index(1)" + ) + ] + """ + } } @Test func `a violated rule fails the statement with its rule and node`() async throws { let report = try await play("app overlap\ncheck siblings-disjoint") - let failure = try #require(report.failures.first) - #expect(report.failures.count == 1) - #expect(failure.verb == "check") - #expect(failure.line == 2) - #expect(failure.statement == "check siblings-disjoint") - #expect(failure.rule == "siblings-disjoint") - #expect(failure.node?.hasPrefix("root") == true) - #expect(failure.message.hasPrefix("siblings-disjoint at root")) - #expect(failure.message.contains("under Overlap")) - #expect(failure.screen?.first == "second") - #expect(report.json.contains(#""rule":"siblings-disjoint""#)) + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 2, + statement: "check siblings-disjoint", + verb: "check", + message: "siblings-disjoint at root/index(0)/index(1): shows 0,0,6x1, which overlaps sibling root/index(0)/index(0) showing 0,0,5x1 under Overlap", + expected: nil, + actual: nil, + screen: [ + [0]: "second" + ], + rule: "siblings-disjoint", + node: "root/index(0)/index(1)" + ) + ] + """ + } + assertInlineSnapshot(of: report.json, as: .lines) { + """ + {"failures":[{"line":2,"message":"siblings-disjoint at root/index(0)/index(1): shows 0,0,6x1, which overlaps sibling root/index(0)/index(0) showing 0,0,5x1 under Overlap","node":"root/index(0)/index(1)","rule":"siblings-disjoint","screen":["second"],"statement":"check siblings-disjoint","verb":"check"}],"observations":[{"line":2,"lines":["ran siblings-disjoint"],"verb":"check"}],"passed":false,"scenario":"t.scn"} + """ + } } @Test @@ -231,13 +337,170 @@ struct NewStatementPlayerTests { let others = try await play( "app overflow\ncheck no-dirty-after-pass siblings-disjoint") - #expect(bare.failures.map(\.rule) == ["measured-within-proposal"]) - let lines = try #require(bare.observations.first?.lines) - #expect(lines.count == 2) - #expect(lines[1].hasPrefix("warning frame-inside-clip root")) - #expect(named.failures.map(\.rule) == ["frame-inside-clip"]) - #expect(named.observations.first?.lines.count == 1) - #expect(others.passed, "\(others)") + assertInlineSnapshot(of: bare, as: .customDump) { + """ + ScenarioReport( + scenario: "t.scn", + failures: [ + [0]: ScenarioFailure( + line: 2, + statement: "check", + verb: "check", + message: "measured-within-proposal at root/index(0)/index(0)/index(0): measured 12 columns where 4 were offered", + expected: nil, + actual: nil, + screen: [ + [0]: "wide", + [1]: "ok" + ], + rule: "measured-within-proposal", + node: "root/index(0)/index(0)/index(0)" + ) + ], + observations: [ + [0]: ScenarioObservation( + line: 2, + verb: "check", + name: nil, + lines: [ + [0]: "ran no-dirty-after-pass, frame-inside-clip, measured-within-proposal, siblings-disjoint, requirements-aggregate", + [1]: "warning frame-inside-clip root/index(0)/index(0)/index(0): frame 0,0,12x1 is clipped to 0,0,4x1 (visible text `wide`)" + ], + projection: nil + ) + ], + recorded: [], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "80x24", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(0)/index(0)", + [4]: "root/index(0)/index(1)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1 + ], + cost: StepCost( + bodies: 0, + bytes: 2097, + measures: 11, + placements: 5, + renders: 2 + ) + ) + ] + ) + """ + } + assertInlineSnapshot(of: named, as: .customDump) { + """ + ScenarioReport( + scenario: "t.scn", + failures: [ + [0]: ScenarioFailure( + line: 2, + statement: "check frame-inside-clip", + verb: "check", + message: "frame-inside-clip at root/index(0)/index(0)/index(0): frame 0,0,12x1 is clipped to 0,0,4x1 (visible text `wide`)", + expected: nil, + actual: nil, + screen: [ + [0]: "wide", + [1]: "ok" + ], + rule: "frame-inside-clip", + node: "root/index(0)/index(0)/index(0)" + ) + ], + observations: [ + [0]: ScenarioObservation( + line: 2, + verb: "check", + name: nil, + lines: [ + [0]: "ran frame-inside-clip" + ], + projection: nil + ) + ], + recorded: [], + seconds: [], + trace: [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "80x24", + detail: nil + ), + drew: true, + reasons: [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(0)/index(0)", + [4]: "root/index(0)/index(1)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0, + [1]: 1 + ], + cost: StepCost( + bodies: 0, + bytes: 2097, + measures: 11, + placements: 5, + renders: 2 + ) + ) + ] + ) + """ + } + expectNoDifference(others.failures, []) } // MARK: dump @@ -248,21 +511,29 @@ struct NewStatementPlayerTests { let report = try await play("app layers\ndump") let observation = try #require(report.observations.first) - #expect(observation.verb == "dump") - #expect(observation.lines.isEmpty) let projection = try #require(observation.projection) - #expect(projection.pass == 0) - #expect(projection.nodes.count > 5) + assertInlineSnapshot( + of: (observation.verb, observation.lines, projection.pass, projection.nodes.count), + as: .customDump + ) { + """ + ( + "dump", + [], + 0, + 13 + ) + """ + } let json = try #require( JSONSerialization.jsonObject(with: Data(report.json.utf8)) as? [String: Any]) let observations = try #require(json["observations"] as? [[String: Any]]) let dumped = try #require(observations.first?["projection"] as? [String: Any]) #expect(dumped["version"] as? Int == 1) #expect((dumped["nodes"] as? [Any])?.count == projection.nodes.count) - #expect( - Set(dumped.keys) == [ - "nodes", "pass", "requirements", "screen", "statistics", "version", - ]) + expectNoDifference( + dumped.keys.sorted(), + ["nodes", "pass", "requirements", "screen", "statistics", "version"]) } @Test @@ -288,7 +559,7 @@ struct NewStatementPlayerTests { expect cost bytes<=100 renders<=1 """) - #expect(report.passed, "\(report)") + expectNoDifference(report.failures, []) } @Test @@ -298,22 +569,46 @@ struct NewStatementPlayerTests { { let report = try await play("app steady\nkey +\nexpect cost bytes<=0 measures<=5") - let failure = try #require(report.failures.first) - #expect(failure.verb == "expect cost") - #expect(failure.line == 3) - #expect(failure.message.hasPrefix("step 1 (key +) took ")) - #expect(failure.message.contains(" bytes, above the bound 0")) - #expect(failure.expected == "bytes<=0 measures<=5") - #expect(failure.actual?.contains(" bytes=") == true) - #expect(failure.actual?.contains("renders=") == true) + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 3, + statement: "expect cost bytes<=0 measures<=5", + verb: "expect cost", + message: "step 1 (key +) took 17 bytes, above the bound 0", + expected: "bytes<=0 measures<=5", + actual: "bodies=1 bytes=17 measures=4 placements=4 renders=1", + screen: nil, + rule: nil, + node: nil + ) + ] + """ + } } @Test func `expect cost before any event bounds the first frame`() async throws { let report = try await play("app layers\nexpect cost bytes<=1") - let failure = try #require(report.failures.first) - #expect(failure.message.hasPrefix("step 0 (resize 80x24) took ")) + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 2, + statement: "expect cost bytes<=1", + verb: "expect cost", + message: "step 0 (resize 80x24) took 2097 bytes, above the bound 1", + expected: "bytes<=1", + actual: "bodies=0 bytes=2097 measures=29 placements=13 renders=5", + screen: nil, + rule: nil, + node: nil + ) + ] + """ + } } @Test @@ -322,7 +617,7 @@ struct NewStatementPlayerTests { let report = try await play( "app steady\ntype \"+x\"\nexpect cost bytes<=0 renders<=0") - #expect(report.passed, "\(report)") + expectNoDifference(report.failures, []) } // MARK: Locators @@ -341,7 +636,7 @@ struct NewStatementPlayerTests { expect node type=ForEach frame 0,4,1x2 """) - #expect(report.passed, "\(report)") + expectNoDifference(report.failures, []) } @Test @@ -350,10 +645,32 @@ struct NewStatementPlayerTests { let shown = try await play("app layers\nexpect node text=atus frame 0,0,6x1") let top = try await play("app layers\nexpect node text=OK frame 0,0,2x1") - #expect( - covered.failures.first?.message == "locator `text=\"status\"` matches no node") - #expect(shown.passed, "\(shown)") - #expect(top.passed, "\(top)") + assertInlineSnapshot(of: covered.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 2, + statement: "expect node text=status frame 0,0,6x1", + verb: "expect node", + message: #"locator `text="status"` matches no node"#, + expected: "0,0,6x1", + actual: "(no node; types: TupleView, VStack, ZStack, Text, _LayoutContainer, ForEach; layouts: _FrameLayout, _PaddingLayout)", + screen: [ + [0]: "OKatus", + [1]: "", + [2]: " Go", + [3]: "", + [4]: "a", + [5]: "b" + ], + rule: nil, + node: nil + ) + ] + """ + } + expectNoDifference(shown.failures, []) + expectNoDifference(top.failures, []) } @Test @@ -362,10 +679,30 @@ struct NewStatementPlayerTests { let report = try await play( "app layers\nexpect node layout=_NoSuchLayout frame 0,0,1x1") - let actual = try #require(report.failures.first?.actual) - #expect(actual.hasPrefix("(no node; types: ")) - #expect(actual.contains("_LayoutContainer")) - #expect(actual.contains("; layouts: _FrameLayout, _PaddingLayout)")) + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 2, + statement: "expect node layout=_NoSuchLayout frame 0,0,1x1", + verb: "expect node", + message: "locator `layout=_NoSuchLayout` matches no node", + expected: "0,0,1x1", + actual: "(no node; types: TupleView, VStack, ZStack, Text, _LayoutContainer, ForEach; layouts: _FrameLayout, _PaddingLayout)", + screen: [ + [0]: "OKatus", + [1]: "", + [2]: " Go", + [3]: "", + [4]: "a", + [5]: "b" + ], + rule: nil, + node: nil + ) + ] + """ + } } @Test @@ -373,10 +710,30 @@ struct NewStatementPlayerTests { { let report = try await play("app layers\nexpect node type=Text frame 0,0,1x1") - let failure = try #require(report.failures.first) - #expect(failure.message.hasPrefix("locator `type=Text` matches ")) - #expect(failure.message.hasSuffix(" nodes; add nth=")) - #expect(failure.actual?.contains("root/index(0)/index(1)") == true) + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 2, + statement: "expect node type=Text frame 0,0,1x1", + verb: "expect node", + message: "locator `type=Text` matches 5 nodes; add nth=", + expected: "0,0,1x1", + actual: "root/index(0)/index(0)/index(0), root/index(0)/index(0)/index(1)/index(0), root/index(0)/index(1)/index(0), root/index(0)/index(2)/id(a)/index(0), root/index(0)/index(2)/id(b)/index(0)", + screen: [ + [0]: "OKatus", + [1]: "", + [2]: " Go", + [3]: "", + [4]: "a", + [5]: "b" + ], + rule: nil, + node: nil + ) + ] + """ + } } // MARK: Trace and shots @@ -385,8 +742,9 @@ struct NewStatementPlayerTests { func `a played scenario carries one trace record per step`() async throws { let report = try await play("app growing\nkey +\ntype \"+-\"\nresize 30x6") - #expect(report.trace.map(\.seq) == [0, 1, 2, 3, 4]) - #expect(report.trace.map(\.event.type) == ["resize", "key", "key", "key", "resize"]) + expectNoDifference(report.trace.map(\.seq), [0, 1, 2, 3, 4]) + expectNoDifference( + report.trace.map(\.event.type), ["resize", "key", "key", "key", "resize"]) } @Test @@ -399,6 +757,22 @@ struct NewStatementPlayerTests { let report = await ScenarioPlayer.run(scenario, name: "t.scn", registry: registry) #expect(report.trace.count == 2) - #expect(report.failures.map(\.verb) == ["expect text"]) + assertInlineSnapshot(of: report.failures, as: .customDump) { + """ + [ + [0]: ScenarioFailure( + line: 3, + statement: #"expect text 0 "x""#, + verb: "expect text", + message: "the application finished at line 2 (`key q`), and in a terminal nothing after it runs", + expected: nil, + actual: nil, + screen: nil, + rule: nil, + node: nil + ) + ] + """ + } } } diff --git a/Tests/TesseraHarnessTests/ScenarioTests.swift b/Tests/TesseraHarnessTests/ScenarioTests.swift index 65f1156..5f78332 100644 --- a/Tests/TesseraHarnessTests/ScenarioTests.swift +++ b/Tests/TesseraHarnessTests/ScenarioTests.swift @@ -1,4 +1,8 @@ +import CustomDump import Foundation +import InlineSnapshotTesting +import SnapshotTesting +import SnapshotTestingCustomDump import TesseraCore import TesseraTerminalInput import Testing @@ -9,6 +13,29 @@ private func parse(_ source: String, file: String = "t.scn") throws -> Scenario try ScenarioParser.parse(source, file: file, knownApps: Fixtures.names) } +/// The parse error `source` raises. +private func parseError(of source: String) -> ScenarioParseError? { + do { + _ = try parse(source) + Issue.record("`\(source)` parsed without an error") + } catch let error as ScenarioParseError { + return error + } catch { + Issue.record(error, "`\(source)` raised a different error") + } + return nil +} + +/// The text of the parse error `source` raises, in the `FILE:LINE: error: message` form. +private func parseFailure(of source: String) -> String { + parseError(of: source)?.description ?? "" +} + +/// The failure text of each source, one per line, for tests that check a family of inputs. +private func parseFailures(of sources: [String]) -> String { + sources.map(parseFailure(of:)).joined(separator: "\n") +} + struct ScenarioParserTests { @Test func `header statements bind before the first body statement`() throws { @@ -22,13 +49,33 @@ struct ScenarioParserTests { """ ) - #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 == 5) + assertInlineSnapshot(of: scenario, as: .customDump) { + """ + Scenario( + app: "stack", + appLine: SourceLine( + number: 2, + text: "app stack" + ), + viewport: .mobile, + sizeLine: SourceLine( + number: 3, + text: "size mobile" + ), + terminal: TerminalProfile( + colors: .ansi16, + theme: .light + ), + lines: [ + [0]: ScenarioLine( + number: 5, + source: #"type "hi there" # trailing comment"#, + statement: .type("hi there") + ) + ] + ) + """ + } } @Test @@ -37,79 +84,93 @@ struct ScenarioParserTests { { let scenario = try parse("app stack") - #expect(scenario.terminal == TerminalProfile(colors: .none, theme: nil)) - #expect(scenario.viewport == .desktop) - #expect(scenario.sizeLine == nil) + assertInlineSnapshot(of: scenario, as: .customDump) { + """ + Scenario( + app: "stack", + appLine: SourceLine( + number: 1, + text: "app stack" + ), + viewport: .desktop, + sizeLine: nil, + terminal: TerminalProfile( + colors: .none, + theme: nil + ), + lines: [] + ) + """ + } } @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") + assertInlineSnapshot(of: parseFailure(of: "size desktop\nkey Tab"), as: .lines) { + """ + t.scn:1: error: a scenario names its app with an `app NAME` header; accepted: stack, workspace, greedy, event-log, counter + """ } - - #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") + assertInlineSnapshot(of: parseFailure(of: "app missing"), as: .lines) { + """ + t.scn:1: error: unknown app `missing`; accepted: stack, workspace, greedy, event-log, counter + """ } - - #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 parse("key Tab\nsize 10x10") + assertInlineSnapshot(of: parseError(of: "key Tab\nsize 10x10"), as: .customDump) { + """ + ScenarioParseError( + file: "t.scn", + line: 2, + source: "size 10x10", + message: "`size` is a header statement; use `resize WxH` after the first body statement" + ) + """ } - - #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)") - } + let failures = parseFailures( + of: ["app stack", "term colors=16"].map { "app stack\nkey Tab\n\($0)" }) - #expect(error?.line == 3, "\(header)") - #expect(error?.message.contains("must precede the first body statement") == true) + assertInlineSnapshot(of: failures, as: .lines) { + """ + t.scn:3: error: `app` is a header statement and must precede the first body statement + t.scn:3: error: `term` is a header statement and must precede the first body statement + """ } } @Test func `unknown statements list the accepted vocabulary`() { - let error = #expect(throws: ScenarioParseError.self) { - try parse("app stack\njump 3") + assertInlineSnapshot(of: parseFailure(of: "app stack\njump 3"), as: .lines) { + """ + t.scn:2: error: unknown statement `jump`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + """ } - - #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 `unknown expectations list the accepted kinds`() { - let error = #expect(throws: ScenarioParseError.self) { - try parse("app stack\nexpect colour red") + assertInlineSnapshot(of: parseFailure(of: "app stack\nexpect colour red"), as: .lines) + { + """ + t.scn:2: error: unknown expectation `colour`; accepted: text, screen, node, cost + """ } - #expect( - error?.message == "unknown expectation `colour`; accepted: text, screen, node, cost" - ) } @Test - func `every statement form the grammar defers is refused by name`() throws { + func `every statement form the grammar defers is refused by name`() { let samples: [String: String] = [ "wait": "wait", "settle": "settle", @@ -128,17 +189,31 @@ struct ScenarioParserTests { "term keyboard=": "term keyboard=kitty", "term theme=probe": "term theme=probe", ] - #expect(Set(samples.keys) == Set(ScenarioGrammar.notYetExpressible.map(\.form))) + expectNoDifference( + samples.keys.sorted(), ScenarioGrammar.notYetExpressible.map(\.form).sorted()) - for (form, sample) in samples { - let error = #expect(throws: ScenarioParseError.self) { - try parse("app stack\n" + sample) - } + let failures = parseFailures( + of: samples.sorted { $0.key < $1.key }.map { "app stack\n" + $0.value }) - #expect(error?.line == 2, "\(form)") - #expect( - error?.message.hasPrefix("`\(form)` is not yet expressible: it needs ") == true, - "\(form): \(error?.message ?? "no error")") + assertInlineSnapshot(of: failures, as: .lines) { + """ + t.scn:2: error: `expect cell` is not yet expressible: it needs style and role assertions on cells. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `expect focused` is not yet expressible: it needs focus tracking. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `expect node SEL clipped` is not yet expressible: it needs the visibility rules of focus tooling. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `expect node SEL count N` is not yet expressible: it needs a later release of the locator language. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `expect node SEL visible` is not yet expressible: it needs the visibility rules of focus tooling. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `expect wireframe` is not yet expressible: it needs catalog conformance. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `focus` is not yet expressible: it needs focus tracking and role and label locators. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `hit` is not yet expressible: it needs pointer input and hit testing. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `mouse` is not yet expressible: it needs pointer input and hit testing. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `settle` is not yet expressible: it needs the schedulable clock. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `shot NAME SEL` is not yet expressible: it needs region shots, with catalog conformance. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `term glyphs=` is not yet expressible: it needs ASCII glyph fallbacks in the views. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `term keyboard=` is not yet expressible: it needs key routing that depends on the protocol. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `term theme=probe` is not yet expressible: it needs semantic roles. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `tree SEL` is not yet expressible: it needs a later release of the locator language. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + t.scn:2: error: `wait` is not yet expressible: it needs the schedulable clock. See the "Not yet expressible" table in Examples/Scenarios/README.md; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost + """ } } @@ -167,31 +242,33 @@ struct ScenarioParserTests { } let rows = section.filter { $0.hasPrefix("| `") }.map(deferred) - #expect( - rows.sorted { $0.form < $1.form } - == ScenarioGrammar.notYetExpressible.sorted { $0.form < $1.form }) + expectNoDifference( + rows.sorted { $0.form < $1.form }, + ScenarioGrammar.notYetExpressible.sorted { $0.form < $1.form }) } @Test func `unknown key names list the accepted set`() { - let error = #expect(throws: ScenarioParseError.self) { - try parse("app stack\nkey Hyperspace") + assertInlineSnapshot(of: parseFailure(of: "app stack\nkey Hyperspace"), as: .lines) { + """ + t.scn:2: error: unknown key `Hyperspace`; accepted: ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Backspace, Delete, End, Enter, Escape, Home, PageDown, PageUp, Space, Tab, F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, a single character; modifiers: Alt, Control, Ctrl, Meta, Shift, Super joined with `+` + """ } - - #expect(error?.message.contains("ArrowLeft") == true) } @Test func `chords and function keys parse into key events`() throws { 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 } - } - #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)) + expectNoDifference( + scenario.lines.map(\.statement), + [ + .key(Key(code: .tab, modifiers: .shift)), + .key(Key(code: .character("c"), modifiers: .control)), + .key(Key(code: .function(5))), + .key(Key(code: .character("+"), modifiers: .control)), + ] + ) } @Test @@ -200,41 +277,46 @@ struct ScenarioParserTests { "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) + let expected = Statement.expectNode( + Locator(id: "root"), + FrameExpectation(column: 1, row: 2, columns: 3, rows: 4) + ) + + expectNoDifference(scenario.lines.map(\.statement), [expected, expected]) } @Test func `unterminated quotes fail with the line number`() { - let error = #expect(throws: ScenarioParseError.self) { - try parse("app stack\n\ntype \"oops") + assertInlineSnapshot(of: parseFailure(of: "app stack\n\ntype \"oops"), as: .lines) { + """ + t.scn:3: error: unterminated double quote + """ } - - #expect(error?.line == 3) } @Test func `duplicate shot names are rejected, naming the first line`() { - let error = #expect(throws: ScenarioParseError.self) { - try parse("app stack\nshot a\nshot a") + assertInlineSnapshot(of: parseFailure(of: "app stack\nshot a\nshot a"), as: .lines) { + """ + t.scn:3: error: duplicate shot name `a` (first used on line 2) + """ } - - #expect(error?.line == 3) - #expect(error?.message == "duplicate shot name `a` (first used on line 2)") } @Test func `shot names that differ only by case collide, because goldens are files`() { - let error = #expect(throws: ScenarioParseError.self) { - try parse("app stack\nshot a\ntype \"+\"\nshot A") + assertInlineSnapshot( + of: parseError(of: "app stack\nshot a\ntype \"+\"\nshot A"), as: .customDump + ) { + """ + ScenarioParseError( + file: "t.scn", + line: 4, + source: "shot A", + message: "shot name `A` collides with `a` on line 2: goldens are files, and the file systems CI runs on fold case" + ) + """ } - - #expect(error?.line == 4) - #expect(error?.source == "shot A") - #expect( - error?.description.hasPrefix("t.scn:4: error: shot name `A` collides with `a`") - == true) - #expect(error?.message.contains("on line 2") == true) - #expect(error?.message.contains("fold case") == true) } @Test @@ -245,19 +327,42 @@ struct ScenarioParserTests { 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\""]) + expectNoDifference(fromCRLF, fromLF) + assertInlineSnapshot(of: fromCRLF.lines, as: .customDump) { + """ + [ + [0]: ScenarioLine( + number: 4, + source: #"type "hi""#, + statement: .type("hi") + ), + [1]: ScenarioLine( + number: 5, + source: #"expect text 0 "x""#, + statement: .expectText( + row: 0, + text: "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") + assertInlineSnapshot( + of: parseError(of: "app stack\r\nsize 40x10\r\nblink\r\n"), as: .customDump + ) { + """ + ScenarioParseError( + file: "t.scn", + line: 3, + source: "blink", + message: "unknown statement `blink`; accepted: app, size, term, resize, key, type, paste, bytes, shot, tree, dump, check, expect text, expect screen, expect node, expect cost" + ) + """ } - - #expect(error?.line == 3) - #expect(error?.source == "blink") } @Test @@ -271,27 +376,25 @@ struct ScenarioParserTests { """# ) - #expect( - scenario.lines.map(\.statement) == [ - .bytes([0x1B, 0x5B, 0x41]), .paste("a b\nc"), .type("AB"), - ]) + expectNoDifference( + 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) - } + let lines = [#"type "\x1""#, #"type "\xZZ""#, #"type "\x80""#, #"type "\q""#] - #expect( - error?.message.contains(expected) == true, "\(line): \(error?.message ?? "")") + assertInlineSnapshot( + of: parseFailures(of: lines.map { "app stack\n" + $0 }), as: .lines + ) { + #""" + t.scn:2: error: `\x` needs two hex digits, as in `\x1b` + t.scn:2: error: `\x` needs two hex digits, as in `\x1b` + t.scn:2: error: `\x` escapes are ASCII (00 to 7f); write a non-ASCII character itself + t.scn:2: error: unknown escape `\q`; accepted: \n \t \" \\ \xHH + """# } } @@ -310,92 +413,165 @@ struct ScenarioParserTests { """ ) - #expect(scenario.lines.count == 2) - #expect( - scenario.lines[0].statement - == .expectScreen(rows: ["Stack demo", " # not a comment", "", "last "])) - #expect(scenario.lines[0].number == 2) - #expect(scenario.lines[0].source == "expect screen < 0) - #expect(first.nodes.added.first == "root") - #expect(first.nodes.changed.isEmpty && first.nodes.removed.isEmpty) - #expect(first.rows == [0]) + #expect(records.first?.version == TraceRecord.currentVersion) + assertInlineSnapshot(of: records, as: .customDump) { + """ + [ + [0]: TraceRecord( + version: 1, + seq: 0, + event: TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x12", + detail: nil + ), + drew: true, + reasons: [ + [0]: "renderRequested", + [1]: "updateRequested" + ], + nodes: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/body", + [3]: "root/index(0)/body/index(0)" + ], + changed: [], + removed: [] + ), + rows: [ + [0]: 0 + ], + cost: StepCost( + bodies: 0, + bytes: 573, + measures: 4, + placements: 4, + renders: 1 + ) + ) + ] + """ + } } @Test @@ -56,11 +94,51 @@ struct TraceTests { { let records = try await records(of: "layers", [key("x"), key("y")]) - #expect(records.map(\.seq) == [0, 1, 2]) + assertInlineSnapshot( + of: records.map { ($0.seq, $0.drew, $0.reasons) }, as: .customDump + ) { + """ + [ + [0]: ( + 0, + true, + [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ] + ), + [1]: ( + 1, + true, + [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ] + ), + [2]: ( + 2, + true, + [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ] + ) + ] + """ + } for record in records { - #expect(record.drew) - #expect(record.reasons.contains("updateRequested"), "step \(record.seq)") - #expect(record.reasons.contains("renderRequested"), "step \(record.seq)") #expect(record.reasons == record.reasons.sorted(), "the library's fixed order") } } @@ -70,11 +148,68 @@ struct TraceTests { let records = try await records( of: "layers", [.resize(TerminalSize(columns: 30, rows: 10)), key("x")]) - #expect(records[1].event == TraceEvent(.resize(TerminalSize(columns: 30, rows: 10)))) - #expect(records[1].reasons.contains("layoutViewportChanged")) - #expect(records[1].reasons.contains("renderViewportChanged")) - #expect(!records[2].reasons.contains("layoutViewportChanged")) - #expect(!records[0].reasons.contains("layoutViewportChanged")) + assertInlineSnapshot(of: records.map { ($0.event, $0.reasons) }, as: .customDump) { + """ + [ + [0]: ( + TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "40x12", + detail: nil + ), + [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ] + ), + [1]: ( + TraceEvent( + type: "resize", + key: nil, + kind: nil, + text: nil, + size: "30x10", + detail: nil + ), + [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "layoutViewportChanged", + [3]: "renderEnvironmentChanged", + [4]: "renderRequested", + [5]: "renderViewChanged", + [6]: "renderViewportChanged", + [7]: "updateRequested" + ] + ), + [2]: ( + TraceEvent( + type: "key", + key: "x", + kind: nil, + text: nil, + size: nil, + detail: nil + ), + [ + [0]: "layoutEnvironmentChanged", + [1]: "layoutViewChanged", + [2]: "renderEnvironmentChanged", + [3]: "renderRequested", + [4]: "renderViewChanged", + [5]: "updateRequested" + ] + ) + ] + """ + } } @Test @@ -83,28 +218,163 @@ struct TraceTests { { let records = try await records(of: "steady", [key("x"), key("+"), key("x")]) - let ignored = records[1] - #expect(!ignored.drew) - #expect( - ignored.cost - == StepCost(bodies: 0, bytes: 0, measures: 0, placements: 0, renders: 0)) - #expect(ignored.rows.isEmpty) - #expect(ignored.nodes == TraceRecord.NodeChanges(added: [], changed: [], removed: [])) - #expect(ignored.reasons == ["updateRequested"]) - #expect(records[2].drew) - #expect(records[2].rows == [0]) - #expect(records[2].cost.bytes > 0) - #expect(!records[3].drew) + assertInlineSnapshot( + of: records.map { ($0.drew, $0.cost, $0.rows, $0.nodes, $0.reasons) }, + as: .customDump + ) { + """ + [ + [0]: ( + true, + StepCost( + bodies: 0, + bytes: 573, + measures: 4, + placements: 4, + renders: 1 + ), + [ + [0]: 0 + ], + TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/body", + [3]: "root/index(0)/body/index(0)" + ], + changed: [], + removed: [] + ), + [ + [0]: "renderRequested", + [1]: "updateRequested" + ] + ), + [1]: ( + false, + StepCost( + bodies: 0, + bytes: 0, + measures: 0, + placements: 0, + renders: 0 + ), + [], + TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + [ + [0]: "updateRequested" + ] + ), + [2]: ( + true, + StepCost( + bodies: 1, + bytes: 17, + measures: 4, + placements: 4, + renders: 1 + ), + [ + [0]: 0 + ], + TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + [ + [0]: "layoutViewChanged", + [1]: "renderRequested", + [2]: "renderViewChanged", + [3]: "updateRequested" + ] + ), + [3]: ( + false, + StepCost( + bodies: 0, + bytes: 0, + measures: 0, + placements: 0, + renders: 0 + ), + [], + TraceRecord.NodeChanges( + added: [], + changed: [], + removed: [] + ), + [ + [0]: "updateRequested" + ] + ) + ] + """ + } } @Test func `a step lists the nodes it added and removed by identity`() async throws { let records = try await records(of: "growing", [key("+"), key("+"), key("-")]) - #expect(records[1].nodes.added.contains { $0.hasSuffix("id(b)") }) - #expect(records[2].nodes.added.contains { $0.hasSuffix("id(c)") }) - #expect(records[3].nodes.removed.contains { $0.hasSuffix("id(c)") }) - #expect(records[3].nodes.added.isEmpty) + assertInlineSnapshot(of: records.map(\.nodes), as: .customDump) { + """ + [ + [0]: TraceRecord.NodeChanges( + added: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(0)/id(a)", + [4]: "root/index(0)/index(0)/id(a)/index(0)" + ], + changed: [], + removed: [] + ), + [1]: TraceRecord.NodeChanges( + added: [ + [0]: "root/index(0)/index(0)/id(b)", + [1]: "root/index(0)/index(0)/id(b)/index(0)" + ], + changed: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)" + ], + removed: [] + ), + [2]: TraceRecord.NodeChanges( + added: [ + [0]: "root/index(0)/index(0)/id(c)", + [1]: "root/index(0)/index(0)/id(c)/index(0)" + ], + changed: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)" + ], + removed: [] + ), + [3]: TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)" + ], + removed: [ + [0]: "root/index(0)/index(0)/id(c)", + [1]: "root/index(0)/index(0)/id(c)/index(0)" + ] + ) + ] + """ + } } @Test @@ -113,40 +383,78 @@ struct TraceTests { let records = try await records( of: "layers", [.resize(TerminalSize(columns: 20, rows: 12))]) - #expect(records[1].nodes.added.isEmpty) - #expect(records[1].nodes.removed.isEmpty) - #expect(!records[1].nodes.changed.contains("root/index(0)/index(2)/id(a)")) - #expect(records[1].nodes.changed.contains("root")) + assertInlineSnapshot(of: records[1].nodes, as: .customDump) { + """ + TraceRecord.NodeChanges( + added: [], + changed: [ + [0]: "root", + [1]: "root/index(0)", + [2]: "root/index(0)/index(0)", + [3]: "root/index(0)/index(1)", + [4]: "root/index(0)/index(2)/id(a)/index(0)", + [5]: "root/index(0)/index(2)/id(b)/index(0)" + ], + removed: [] + ) + """ + } } // MARK: Events @Test func `an event is spelled the way a scenario statement spells it`() { - #expect(TraceEvent(.key(Key(code: .down))).key == "ArrowDown") - #expect(TraceEvent(.key(Key(code: .tab, modifiers: .shift))).key == "Shift+Tab") - #expect( - TraceEvent(.key(Key(code: .character("c"), modifiers: .control))).key == "Ctrl+c") - #expect(TraceEvent(.key(Key(code: .character(" ")))).key == "Space") + let keys = [ + Key(code: .down), Key(code: .tab, modifiers: .shift), + Key(code: .character("c"), modifiers: .control), Key(code: .character(" ")), + ] + let kinds = [KeyEventKind.release, .repeat, .press].map { + TraceEvent(.key(Key(code: .character("a"), kind: $0))).kind + } + + expectNoDifference( + keys.map { TraceEvent(.key($0)).key }, + ["ArrowDown", "Shift+Tab", "Ctrl+c", "Space"]) + expectNoDifference(kinds, ["release", "repeat", nil]) #expect(TraceEvent(.paste("a b")).text == "a b") #expect(TraceEvent(.resize(TerminalSize(columns: 9, rows: 4))).size == "9x4") - #expect( - TraceEvent(.key(Key(code: .character("a"), kind: .release))).kind == "release") - #expect(TraceEvent(.key(Key(code: .character("a"), kind: .repeat))).kind == "repeat") - #expect(TraceEvent(.key(Key(code: .character("a")))).kind == nil) } @Test func `an event neither the grammar nor replay can say is recorded as other`() { - for event in [InputEvent.unknown([0x1b, 0x5b]), .primaryDeviceAttributes([1])] { - let recorded = TraceEvent(event) - #expect(recorded.type == "other") - #expect(recorded.inputEvent == nil) - #expect(recorded.summary.hasSuffix("(not replayable)")) + let recorded = [ + InputEvent.unknown([0x1b, 0x5b]), .primaryDeviceAttributes([1]), + .key(Key(code: .character("a"), modifiers: .capsLock)), + ].map(TraceEvent.init) + + assertInlineSnapshot( + of: recorded.map { ($0.type, $0.inputEvent == nil, $0.summary, $0.detail) }, + as: .customDump + ) { + """ + [ + [0]: ( + "other", + true, + "unknown (not replayable)", + "unknown" + ), + [1]: ( + "other", + true, + "terminalReply (not replayable)", + "terminalReply" + ), + [2]: ( + "other", + true, + "key a (not replayable)", + "key a" + ) + ] + """ } - let capsLock = TraceEvent(.key(Key(code: .character("a"), modifiers: .capsLock))) - #expect(capsLock.type == "other") - #expect(capsLock.detail?.hasPrefix("key") == true) } @Test @@ -154,12 +462,24 @@ struct TraceTests { let gained = TraceEvent(.focusGained) let lost = TraceEvent(.focusLost) - #expect(gained.type == "focusGained") - #expect(lost.type == "focusLost") - #expect(gained.inputEvent == .focusGained) - #expect(lost.inputEvent == .focusLost) - #expect(gained.summary == "focus gained") - #expect(lost.summary == "focus lost") + assertInlineSnapshot( + of: [gained, lost].map { ($0.type, $0.inputEvent, $0.summary) }, as: .customDump + ) { + """ + [ + [0]: ( + "focusGained", + .focusGained, + "focus gained" + ), + [1]: ( + "focusLost", + .focusLost, + "focus lost" + ) + ] + """ + } // Nothing else rides along, so a record is just the type. #expect(JSONText.string(for: gained) == #"{"type":"focusGained"}"#) let decoded = try JSONDecoder().decode( @@ -189,11 +509,11 @@ struct TraceTests { let line = try #require(records.first?.line) #expect(!line.contains("\n")) - let keys = ["cost", "drew", "event", "nodes", "reasons", "rows", "seq", "version"] - let positions = keys.compactMap { line.range(of: "\"\($0)\":")?.lowerBound } - #expect(positions.count == keys.count) - #expect(positions == positions.sorted()) - #expect(line.contains(#""version":1"#)) + assertInlineSnapshot(of: line, as: .lines) { + """ + {"cost":{"bodies":0,"bytes":573,"measures":4,"placements":4,"renders":1},"drew":true,"event":{"size":"40x12","type":"resize"},"nodes":{"added":["root","root/index(0)","root/index(0)/body","root/index(0)/body/index(0)"],"changed":[],"removed":[]},"reasons":["renderRequested","updateRequested"],"rows":[0],"seq":0,"version":1} + """ + } } @Test @@ -204,8 +524,8 @@ struct TraceTests { #expect(text.hasSuffix("}\n")) #expect(text.split(separator: "\n").count == records.count) - #expect(try TraceFile.records(in: text) == records) - #expect(try TraceFile.records(in: "\n" + text + "\n\n") == records) + expectNoDifference(try TraceFile.records(in: text), records) + expectNoDifference(try TraceFile.records(in: "\n" + text + "\n\n"), records) } @Test @@ -227,11 +547,24 @@ struct TraceTests { in: valid.line.replacingOccurrences(of: #""version":1"#, with: #""version":2"#)) } - #expect(notJSON?.line == 2) - #expect(notJSON?.message.contains("not a trace record") == true) - #expect(missing?.line == 2) - #expect(later?.line == 1) - #expect(later?.message.contains("version 2") == true) + assertInlineSnapshot(of: [notJSON, missing, later], as: .customDump) { + """ + [ + [0]: TraceFile.ReadError( + line: 2, + message: "this line is not a trace record (not valid JSON)" + ), + [1]: TraceFile.ReadError( + line: 2, + message: "this line is not a trace record (no `seq`)" + ), + [2]: TraceFile.ReadError( + line: 1, + message: "the record has version 2; this harness reads version 1" + ) + ] + """ + } } @Test @@ -249,27 +582,41 @@ struct TraceTests { try TraceFile.records(in: #"{"seq":0}"# + "\n") } - #expect(renamed?.line == 1) - #expect(renamed?.message == "the record has version 2; this harness reads version 1") - #expect(older?.message == "the record has version 0; this harness reads version 1") - #expect(noVersion?.message.contains("not a trace record") == true) - #expect(noVersion?.message.contains("version") == true) + assertInlineSnapshot(of: [renamed, older, noVersion], as: .customDump) { + """ + [ + [0]: TraceFile.ReadError( + line: 1, + message: "the record has version 2; this harness reads version 1" + ), + [1]: TraceFile.ReadError( + line: 1, + message: "the record has version 0; this harness reads version 1" + ), + [2]: TraceFile.ReadError( + line: 1, + message: "this line is not a trace record (no `version`)" + ) + ] + """ + } } @Test func `a scenario's trace file mirrors its path under the directory and stays inside it`() { - #expect( - TraceFile.path(forScenario: "Examples/Scenarios/flex-desktop.scn", in: "out") - == "out/Examples/Scenarios/flex-desktop.trace.jsonl") - #expect( - TraceFile.path(forScenario: "/tmp/../etc/x.scn", in: "out") - == "out/tmp/etc/x.trace.jsonl") - #expect( - TraceFile.path(forScenario: "./a/./b.scn", in: "out") == "out/a/b.trace.jsonl") - #expect( - TraceFile.path(forScenario: #"C:\work\s.scn"#, in: "out") - == "out/C_/work/s.trace.jsonl") + let scenarios = [ + "Examples/Scenarios/flex-desktop.scn", "/tmp/../etc/x.scn", "./a/./b.scn", + #"C:\work\s.scn"#, + ] + + expectNoDifference( + scenarios.map { TraceFile.path(forScenario: $0, in: "out") }, + [ + "out/Examples/Scenarios/flex-desktop.trace.jsonl", "out/tmp/etc/x.trace.jsonl", + "out/a/b.trace.jsonl", "out/C_/work/s.trace.jsonl", + ] + ) } } diff --git a/Tests/TesseraHarnessTests/TypeNameTests.swift b/Tests/TesseraHarnessTests/TypeNameTests.swift index 7ca659e..d8520eb 100644 --- a/Tests/TesseraHarnessTests/TypeNameTests.swift +++ b/Tests/TesseraHarnessTests/TypeNameTests.swift @@ -1,3 +1,4 @@ +import CustomDump import Testing @testable import TesseraHarness @@ -5,13 +6,14 @@ import Testing struct TypeNameTests { @Test func `a short name drops the module and every generic argument`() { - #expect(TypeName.short("TesseraCore.Text") == "Text") - #expect( - TypeName.short( - "TesseraLayout.VStack>") - == "VStack") - #expect(TypeName.short("MyApp.Outer.Inner") == "Inner") - #expect(TypeName.short("Text") == "Text") + let reflected = [ + "TesseraCore.Text", + "TesseraLayout.VStack>", + "MyApp.Outer.Inner", "Text", + ] + + expectNoDifference( + reflected.map(TypeName.short), ["Text", "VStack", "Inner", "Text"]) } @Test @@ -33,46 +35,50 @@ struct TypeNameTests { let custom = "TesseraCore._LayoutContainer, TesseraCore.Text>" #expect(TypeName.short(frame) == "_LayoutContainer") - #expect(TypeName.layout(of: frame) == "_FrameLayout") - #expect(TypeName.layout(of: padding) == "_PaddingLayout") - #expect(TypeName.layout(of: custom) == "Stagger") + expectNoDifference( + [frame, padding, custom].map(TypeName.layout(of:)), + ["_FrameLayout", "_PaddingLayout", "Stagger"]) } @Test func `only a layout container has a layout`() { - #expect(TypeName.layout(of: "TesseraLayout.HStack") == nil) - #expect(TypeName.layout(of: "TesseraCore.Text") == nil) - #expect(TypeName.layout(of: "TesseraCore._LayoutContainer") == nil) + let reflected = [ + "TesseraLayout.HStack", "TesseraCore.Text", + "TesseraCore._LayoutContainer", + ] + + expectNoDifference(reflected.map(TypeName.layout(of:)), [nil, nil, nil]) } @Test func `the library's view lists are lists whatever they hold`() { - #expect( - TypeName.isViewList( - "TesseraCore.ForEach, TesseraCore.Text>")) - #expect( - TypeName.isViewList("TesseraCore.TupleView<(TesseraCore.Text, TesseraCore.Text)>")) - #expect( - TypeName.isViewList( - "TesseraCore.ConditionalView")) - #expect(TypeName.isViewList("TesseraCore.EmptyView")) - #expect(TypeName.isViewList("Swift.Optional")) + let lists = [ + "TesseraCore.ForEach, TesseraCore.Text>", + "TesseraCore.TupleView<(TesseraCore.Text, TesseraCore.Text)>", + "TesseraCore.ConditionalView", + "TesseraCore.EmptyView", "Swift.Optional", + ] + + expectNoDifference(lists.map(TypeName.isViewList), Array(repeating: true, count: 5)) } @Test func `a view of an app's own with a list's name is not a list`() { - #expect(!TypeName.isViewList("MyApp.ForEach")) - #expect(!TypeName.isViewList("MyApp.TupleView")) - #expect(!TypeName.isViewList("MyApp.Optional")) - #expect(!TypeName.isViewList("MyApp.Outer.EmptyView")) - #expect(!TypeName.isViewList("ForEach")) + let views = [ + "MyApp.ForEach", "MyApp.TupleView", "MyApp.Optional", + "MyApp.Outer.EmptyView", "ForEach", + ] + + expectNoDifference(views.map(TypeName.isViewList), Array(repeating: false, count: 5)) } @Test func `a view that only has a list inside its arguments is not a list`() { - #expect( - !TypeName.isViewList("TesseraLayout.VStack>")) - #expect(!TypeName.isViewList("TesseraCore.Text")) - #expect(!TypeName.isViewList("Swift.Array>")) + let views = [ + "TesseraLayout.VStack>", "TesseraCore.Text", + "Swift.Array>", + ] + + expectNoDifference(views.map(TypeName.isViewList), Array(repeating: false, count: 3)) } } From 3041a7b2319bdb313db91e4c926fb08c86261079 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 12:59:17 -0400 Subject: [PATCH 32/55] refactor(harness): spell the scratch directory with forward slashes A backslash in a dumped string makes the dump quote it differently, so a Windows scratch path redacted to text that no other platform produced; forward slashes keep the snapshots the same everywhere. --- Tests/TesseraHarnessTests/ScratchDirectory.swift | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/Tests/TesseraHarnessTests/ScratchDirectory.swift b/Tests/TesseraHarnessTests/ScratchDirectory.swift index 0478ac2..beaafe3 100644 --- a/Tests/TesseraHarnessTests/ScratchDirectory.swift +++ b/Tests/TesseraHarnessTests/ScratchDirectory.swift @@ -17,8 +17,12 @@ func pathBesideSource(_ relative: String, file: String = #filePath) -> String { final class ScratchDirectory: Sendable { let path: String + /// Spelled with forward slashes on every platform: a backslash in a dumped string makes the + /// dump choose a different quoting, so a Windows path would not redact to the same text. init() throws { - path = NSTemporaryDirectory() + "tessera-harness-\(UUID().uuidString)" + path = + NSTemporaryDirectory().replacingOccurrences(of: "\\", with: "/") + + "tessera-harness-\(UUID().uuidString)" try FileManager.default.createDirectory( atPath: path, withIntermediateDirectories: true) } @@ -48,15 +52,8 @@ final class ScratchDirectory: Sendable { /// `text` with this directory's path shown as ``, so text that names a file in it is /// the same in every run. - /// - /// A path with backslashes appears in a dump with each one doubled, and in the JSON a dump - /// holds with each one doubled again, so every spelling of it is replaced, the longest first. func redacted(_ text: String) -> String { - [4, 2, 1].reduce(text) { text, factor in - let spelled = path.replacingOccurrences( - of: "\\", with: String(repeating: "\\", count: factor)) - return text.replacingOccurrences(of: spelled, with: "") - } + text.replacingOccurrences(of: path, with: "") } /// `value` as a custom dump with the directory's path redacted. From 3feb5cea57c53d6ce7e0fa835682a14306936818 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 13:00:31 -0400 Subject: [PATCH 33/55] refactor(harness): redact the trace file spelling of the scratch path A trace file's name mirrors its scenario's path, so the scratch directory's path appears there in a second spelling on Windows; redact that one too. --- Tests/TesseraHarnessTests/ScratchDirectory.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Tests/TesseraHarnessTests/ScratchDirectory.swift b/Tests/TesseraHarnessTests/ScratchDirectory.swift index beaafe3..0ac88ef 100644 --- a/Tests/TesseraHarnessTests/ScratchDirectory.swift +++ b/Tests/TesseraHarnessTests/ScratchDirectory.swift @@ -52,8 +52,14 @@ final class ScratchDirectory: Sendable { /// `text` with this directory's path shown as ``, so text that names a file in it is /// the same in every run. + /// + /// A trace file's name mirrors the path of its scenario under the trace directory, with a + /// drive letter's colon replaced, so that spelling of the path is redacted too. func redacted(_ text: String) -> String { - text.replacingOccurrences(of: path, with: "") + let mirrored = TraceFile.path(forScenario: path, in: "").dropLast( + ".trace.jsonl".count) + return text.replacingOccurrences(of: mirrored, with: "") + .replacingOccurrences(of: path, with: "") } /// `value` as a custom dump with the directory's path redacted. From 2c463f5d8c1f7d78d376d733c931570f4069db19 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 13:32:58 -0400 Subject: [PATCH 34/55] docs(skills): split the devtools skill into a published tessera skill and a contributor skill --- .agents/skills/tessera-contributing/SKILL.md | 53 +++++++++ .agents/skills/tessera-devtools/SKILL.md | 59 ---------- .agents/skills/tessera/SKILL.md | 108 ++++++++++++++++++ AGENTS.md | 2 +- CHANGELOG.md | 2 +- docs/architecture/developer-tooling.md | 17 +-- ...hase-4-view-layer-and-developer-tooling.md | 15 +-- 7 files changed, 181 insertions(+), 75 deletions(-) create mode 100644 .agents/skills/tessera-contributing/SKILL.md delete mode 100644 .agents/skills/tessera-devtools/SKILL.md create mode 100644 .agents/skills/tessera/SKILL.md diff --git a/.agents/skills/tessera-contributing/SKILL.md b/.agents/skills/tessera-contributing/SKILL.md new file mode 100644 index 0000000..7040092 --- /dev/null +++ b/.agents/skills/tessera-contributing/SKILL.md @@ -0,0 +1,53 @@ +--- +name: tessera-contributing +description: Use Tessera's own demos and developer tools while working in this repository. Use when running `mise run dev:*`, launching or snapshot-testing a demo, adding a demo, or fixing a library defect a demo shows. Read with the `tessera` skill, which it extends. +version: 0.1.0 +--- + +# Contributing to Tessera with the developer tools + +Everything in the [`tessera` skill](../tessera/SKILL.md) applies here: the `tessera` commands, the exit codes, and the pty recipe. This skill adds what only a Tessera contributor has: the `mise` tasks, the demos, and the demo snapshot tests. [Developer tooling](../../../docs/architecture/developer-tooling.md) is the design behind the tools. + +## Commands + +| mise | tessera | Does | +| --- | --- | --- | +| `mise run dev:apps` | `tessera apps` | List the registered demos: name, minimum size, viewports. | +| `mise run dev:apps --json` or `--pretty` | `tessera apps --json` or `--pretty` | Force the JSON object or the text table. | +| `mise run dev:launch NAME` | `tessera launch NAME` | Run one demo in this terminal until it quits. | + +`mise run dev:*` builds and runs the `tessera` executable (`swift run --package-path Examples tessera …`). It is the product of `Examples/Sources/TesseraDev`, whose registry is `Demos.registry()` from `Examples/Sources/DemoSupport`, so the apps it lists are Tessera's demos. + +## Run a demo + +`mise run dev:launch counter` opens the counter in your terminal. Each demo quits on `q`, matched by `isQuitKey` in `Examples/Sources/DemoSupport`. An agent without a terminal uses the pty recipe from the `tessera` skill with `mise run dev:launch counter` in place of `swift run tessera launch counter`; a headless snapshot test is the cheaper way to read a screen. + +## Snapshot-test a demo + +A demo's snapshot test is a few lines in `Examples/Tests/DemoSupportTests`, run by `swift test --package-path Examples`: + +```swift +@Test @MainActor +func `the counter counts`() async throws { + try await Demos.registry().withHeadlessHost("counter", at: .mobile) { host in + try await host.send(.key(Key(code: .character("+")))) + #expect(host.screenRows[2].hasSuffix("1")) + } +} +``` + +`withHeadlessHost` opens a terminal session over a headless device, steps the demo through the same driver as `launch`, and feeds every frame to one Ghostty virtual terminal, so `host.screenRows` is the screen a terminal would show. `@testable import TesseraHarness` reaches it; it is not API for app authors. The viewport names are the ones the `tessera` skill lists. + +## Add a demo + +1. Write a `final class` conforming to `Demo` in `Examples/Sources/DemoSupport`: a kebab-case `name`, a `minimum` size, `viewports`, and `fixtures` (empty when there are none). None has a default. +2. Handle the quit key in `apply(_:)` with `isQuitKey`, and report it through `isFinished`. +3. Register it with one line in `Demos.registry()`. +4. Add its snapshot test beside the counter's. + +A demo is interactive and shows every style or variant that can differ. It uses only what the library provides. + +## Rules + +- When a demo shows a library defect, fix the library and add a regression test. Never bend the demo to hide it. +- A name registered twice fails every command that reads the registry. Never rely on the later registration winning. diff --git a/.agents/skills/tessera-devtools/SKILL.md b/.agents/skills/tessera-devtools/SKILL.md deleted file mode 100644 index a1c7558..0000000 --- a/.agents/skills/tessera-devtools/SKILL.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -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/skills/tessera/SKILL.md b/.agents/skills/tessera/SKILL.md new file mode 100644 index 0000000..0391c83 --- /dev/null +++ b/.agents/skills/tessera/SKILL.md @@ -0,0 +1,108 @@ +--- +name: tessera +description: Drive and check an app built on Tessera with the `tessera` command line. Use when writing a Tessera `Application`, registering it in an `ApplicationRegistry`, wiring the executable that calls `HarnessCLI.main(registry:)`, listing or launching the app with `tessera apps` and `tessera launch`, or reading what the harness reports about it. +version: 0.1.0 +--- + +# Tessera for app authors + +`TesseraHarness` runs a Tessera app live in a terminal and headlessly through one driver, so what a check renders is what a terminal shows. The `tessera` command line is how you and an agent reach it: you register your apps, wire one executable, and run commands against it. Use only the commands listed here. + +## Wire the executable + +1. Conform your app to `Application` where the type is declared: a `final class` with a `body`, an `apply(_:)` that receives each input event the views left unhandled (and every resize), and an `isFinished` that tells the harness to stop. The harness has no quit key of its own; the app decides which key quits. +2. Register it in an `ApplicationRegistry` under a kebab-case name, with its minimum size and the viewports it is checked at. A viewport is `.desktop` (80x24), `.mobile` (40 columns at the height the app's content needs), `.mobileScreen` (40x16), or `.custom(TerminalSize)`. There is no default viewport list. An app whose content grows to any height it is offered has no natural height, so `.mobile` fails and names `.mobileScreen`. +3. Make an executable whose body is `HarnessCLI.main(registry:)`, and name its product `tessera` so every command below runs as `swift run tessera COMMAND`. + +```swift +// Package.swift +let package = Package( + name: "MyApplication", + platforms: [.macOS(.v26)], + products: [.executable(name: "tessera", targets: ["MyAppDev"])], + dependencies: [ + .package(url: "https://github.com/robfeldmann/tessera.git", branch: "main") + ], + targets: [ + .target(name: "MyApp", dependencies: [.product(name: "Tessera", package: "tessera")]), + .executableTarget( + name: "MyAppDev", + dependencies: ["MyApp", .product(name: "TesseraHarness", package: "tessera")] + ), + ] +) +``` + +```swift +// Sources/MyApp/Counter.swift +import Tessera + +public final class Counter: Application { + public private(set) var count = 0 + public private(set) var isFinished = false + + public init() {} + + public var body: some View { + VStack { + Text("Count: \(count)") + Text("+ up q quit") + } + } + + public func apply(_ event: InputEvent) { + guard case .key(let key) = event, key.kind == .press else { return } + switch key.code { + case .character("+"): count += 1 + case .character("q"): isFinished = true + default: break + } + } +} +``` + +```swift +// Sources/MyAppDev/main.swift +import MyApp +import Tessera +import TesseraHarness + +var registry = ApplicationRegistry() +registry.register( + "counter", + minimum: TerminalSize(columns: 20, rows: 3), + viewports: [.desktop, .mobile] +) { Counter() } +await HarnessCLI.main(registry: registry) +``` + +The factory closure runs on the main actor once per run, so every run starts from a fresh app. A further registration of the same app in another starting state is one more `register` call under another name. + +## Commands + +| tessera | Does | +| --- | --- | +| `tessera apps` | List the registered apps: name, minimum size, viewports. | +| `tessera apps --json` or `--pretty` | Force the JSON object or the text table. | +| `tessera launch NAME` | Run one app in this terminal until the app quits. | +| `tessera help [COMMAND]` | Print the commands, or one command's usage. | + +Standard output is one JSON object when it is not a terminal, and a text table when it is. `apps` prints `{"apps": [{"minimum", "name", "viewports"}], "command": "apps"}`, sorted by name, where `minimum` is `null` for an app that declares none. A failed command prints `{"command", "exitCode", "message"}` instead. + +Exit codes: 0 success, 2 a usage error (an unknown command or app name, with the accepted names listed), 3 a harness error (a name registered twice, or `launch` without a terminal). + +## Run the app + +`swift run tessera launch counter` opens the app in your terminal. It needs a real terminal on standard input and output. An agent without one runs it under a pty, after building so compiler output stays out of the capture: + +```sh +swift build --product tessera +expect -c 'set timeout 20; spawn sh -c "stty rows 24 cols 80; exec swift run tessera launch counter"; expect "Count"; send "+"; send "q"; expect eof' +``` + +Read the screen from the bytes the run wrote: cursor addressing, then text. + +## Rules + +- When Tessera itself misbehaves (a crash, a wrong render, an input it drops), report a Tessera bug with the command, its output, and the smallest app that shows it. Do not work around it in the app. +- A name registered twice fails every command that reads the registry. Never rely on the later registration winning. diff --git a/AGENTS.md b/AGENTS.md index ce6a2c4..2c76590 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`; running, launching, and snapshot-testing Tessera's demos follows `tessera-devtools`. Slash commands for the recurring workflows live in `.agents/commands/`. +- Plans and research records follow the `planning` and `investigations` skills in `.agents/skills/`; the design catalog follows `design-catalog`; driving and checking a Tessera app with the `tessera` command line follows the `tessera` skill, and running, launching, and snapshot-testing Tessera's own demos follows `tessera-contributing`, which extends it. Slash commands for the recurring workflows live in `.agents/commands/`. - Markdown prose is one paragraph per line (no hard wrapping); relative `.md` links; no raw HTML. Swift code keeps the formatter's 90-column width. - Swift Testing tests should use backticked, sentence-style function names. - Prefer snapshots for structured state humans inspect as a whole; prefer direct assertions for small scalar/API-shape behavior. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6938057..5d803df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - 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 the `tessera` agent skill for app authors (writing an `Application`, registering it, wiring the executable over `HarnessCLI.main(registry:)`, and `tessera apps` and `tessera launch`) and the `tessera-contributing` skill for contributors (the `mise run dev:*` tasks, the demos, and their snapshot tests), with a pointer from `AGENTS.md`. - Added 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. diff --git a/docs/architecture/developer-tooling.md b/docs/architecture/developer-tooling.md index 409fbd3..d7bc02b 100644 --- a/docs/architecture/developer-tooling.md +++ b/docs/architecture/developer-tooling.md @@ -5,7 +5,7 @@ description: What Tessera's developer tools are, how they fit together, and the # Developer tooling -This page describes Tessera's developer tools: what each one is, how they fit together, and the rules they follow. They serve two audiences: contributors building Tessera, and authors of apps built on Tessera, who drive and check their own apps with the same harness, scenarios, and checks. Each tool's status in [The tools](#the-tools) says whether it exists yet or is only designed. The how-to for using them is the `tessera-devtools` skill (and, later, a user guide); this page is the design they implement. +This page describes Tessera's developer tools: what each one is, how they fit together, and the rules they follow. They serve two audiences: contributors building Tessera, and authors of apps built on Tessera, who drive and check their own apps with the same harness, scenarios, and checks. Each tool's status in [The tools](#the-tools) says whether it exists yet or is only designed. The how-to for using them is the `tessera` skill for app authors and `tessera-contributing` for contributors (and, later, a user guide); this page is the design they implement. ## Why the tools exist @@ -64,7 +64,8 @@ Read the diagram as one scenario run. `tessera run` and `swift test` are two way | Pointer verbs and hit query | both | scenario `mouse`, `hit` | designed | | Schedulable clock | both | environment `Clock` plus scenario `wait`, `settle` | designed | | Live inspection socket | agents | `TESSERA_DEBUG_SOCKET`, `tessera attach` | designed | -| `tessera-devtools` skill | agents | `.agents/skills/tessera-devtools/` | built for the tools above; it grows with each tool | +| `tessera` skill | agents of app authors | `.agents/skills/tessera/` | built; covers each tool built above | +| `tessera-contributing` skill | agents of contributors | `.agents/skills/tessera-contributing/` | built; covers the `mise` tasks and the demos, over the `tessera` skill | ### Demos @@ -201,14 +202,16 @@ The gallery (`DevTools/gallery/`, served by `scripts/gallery-serve.py`) shows ea ## The development loop -The loop the `tessera-devtools` skill teaches, for humans and agents alike: +The loop the `tessera` skill teaches app authors, for humans and agents alike: -1. `tessera launch NAME` to see the component. -2. `tessera run Examples/Scenarios/NAME.scenario` and read the report (or its JSON). +1. `tessera launch NAME` to see the app. +2. `tessera run PATH` on the app's scenario, and read the report (or its JSON). 3. `shot` to picture the failing state, `tree` to see the nodes and frames. 4. Read the trace to learn why a redraw or a focus move happened. 5. `check` to find the broken invariant. -6. Fix the library, never the demo, and rerun. +6. Fix the app and rerun; when the fault is Tessera's, report a Tessera bug and do not work around it. + +The `tessera-contributing` skill runs the same loop over Tessera's own demos: through `mise run dev:*`, on a scenario from `Examples/Scenarios`, and with step 6 as fix the library, add a regression test, and never bend the demo. ## Open questions @@ -216,7 +219,7 @@ The loop the `tessera-devtools` skill teaches, for humans and agents alike: ## Keeping this page current -This page is the index for the tools. A change that adds or changes a tool updates the matching section here, and the `tessera-devtools` skill, in the same change: what the tool is now, what it does not do yet, and its status in [The tools](#the-tools). When a tool grows past a section, its detail moves to its own page under `docs/architecture/developer-tooling/`, and this page keeps a summary and a link. How-tos belong in the skill and the user guide, not here. +This page is the index for the tools. A change that adds or changes a tool updates the matching section here and the skills, in the same change: the `tessera` skill first, as an app author sees the tool, then `tessera-contributing` for what only a contributor has. The page says what the tool is now, what it does not do yet, and its status in [The tools](#the-tools); the skills describe only what exists. When a tool grows past a section, its detail moves to its own page under `docs/architecture/developer-tooling/`, and this page keeps a summary and a link. How-tos belong in the skill and the user guide, not here. ## Affected plans diff --git a/docs/plans/phase-4-view-layer-and-developer-tooling.md b/docs/plans/phase-4-view-layer-and-developer-tooling.md index 9c612ce..ffdff9c 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -8,7 +8,7 @@ description: Build the Phase 4 view layer from the omp2-reviewed catalog and the # Build the Phase 4 view layer with the tooling that proves it -Goal: Tessera's 1.0 view layer (styling, focus, pointer, scrolling, controls, collections, SplitView, the remaining views, and an intro app) lands component by component from the `ready` catalog docs, and every component arrives with a demo that runs live and headlessly, a scenario that an agent can run without prose, and the harness checks that catch the defect classes the archived attempts missed. When this is done, `mise run dev:run Examples/Scenarios` passes on macOS and Linux, `conform` gates every `implemented` catalog page, and a fresh agent given only the `tessera-devtools` skill can drive a demo, read a failure, and take a shot. +Goal: Tessera's 1.0 view layer (styling, focus, pointer, scrolling, controls, collections, SplitView, the remaining views, and an intro app) lands component by component from the `ready` catalog docs, and every component arrives with a demo that runs live and headlessly, a scenario that an agent can run without prose, and the harness checks that catch the defect classes the archived attempts missed. When this is done, `mise run dev:run Examples/Scenarios` passes on macOS and Linux, `conform` gates every `implemented` catalog page, and a fresh agent given only the `tessera` skill can drive an app, read a failure, and take a shot. This plan merges plan 032 (Phases 8-17, its open implementation phases; Phases 1-7 are complete and stay there as history) and plan 033 (the developer tooling, with its spikes, decisions, and owner feedback). Both are cancelled and link here (plan 035 Step 6.3). @@ -43,7 +43,7 @@ Current state. `main` carries Phase 4 Slices 1, 2, and 2.5 (`TesseraCore`, `Tess End state. The components of `docs/spec/phase-4/` "The 1.0 surface" are implemented and graduated into DocC, each demo is a snapshot fixture at its declared viewports, `Examples/Scenarios/` is the behaviour corpus that CI runs on macOS, Linux, and Windows, and `TesseraRuntime` (the `Application` contract, re-exported by `Tessera`), `TesseraHarness` (root package; the library app authors import to drive and check their own apps), `Examples/Sources/DemoSupport`, and `Examples/Sources/TesseraDev` (the target of the `tessera` executable) are the tooling that plan 033 designed. -Patterns to follow. Each step starts from a `ready` catalog doc (finishing it is the first part of the step). Each component lands with its tests, its demo, and its scenario in one reviewable step; port from the archive tags (`archive/phase4`, `archive/phase4-review-loop`, `archive/phase4-unicode-demo-stash`, `archive/catalog-workbench`) only after reading the code against the doc, using the port map in `docs/research/phase-4-salvage-review.md`. Measurement and `body` stay pure; every `NodeState` row has one owner; collections key by stable IDs and fail closed on duplicates. The harness never compensates for a library bug (demos find bugs; the library is fixed). Text goldens are the only committed artefacts; drift fails without `--record`. No test affordances in product API: `TesseraCore` gains only value-typed diagnostics fields, each by spec amendment. A slice that adds or changes a tool updates [Developer tooling](../architecture/developer-tooling.md) (or the per-tool page it links) and the `tessera-devtools` skill before the slice is checked off, so the page describes the tools as built plus what is still only designed, and agents building later slices use the skill as they go. The app contract is Decision 9 (i) and the spec's [Runtime isolation](../spec/phase-5-runtime-and-polish.md#runtime-isolation) section: `Application` carries no isolation, a host is a `@MainActor` shell over one nonisolated step that takes `isolated any Actor`, and isolation mistakes are proved by compile-fail fixtures, never by `assumeIsolated`. The harness's public names are the seven the stable-surface decision lists; every other declaration in it is `package` or internal. "Demo" is the word for Tessera's own per-component example apps; the CLI and the scenario header say `app`. +Patterns to follow. Each step starts from a `ready` catalog doc (finishing it is the first part of the step). Each component lands with its tests, its demo, and its scenario in one reviewable step; port from the archive tags (`archive/phase4`, `archive/phase4-review-loop`, `archive/phase4-unicode-demo-stash`, `archive/catalog-workbench`) only after reading the code against the doc, using the port map in `docs/research/phase-4-salvage-review.md`. Measurement and `body` stay pure; every `NodeState` row has one owner; collections key by stable IDs and fail closed on duplicates. The harness never compensates for a library bug (demos find bugs; the library is fixed). Text goldens are the only committed artefacts; drift fails without `--record`. No test affordances in product API: `TesseraCore` gains only value-typed diagnostics fields, each by spec amendment. A slice that adds or changes a tool updates [Developer tooling](../architecture/developer-tooling.md) (or the per-tool page it links) and the skills before the slice is checked off, the `tessera` skill first, written as an app author sees the tool against the stable surface, then `tessera-contributing` for what only a contributor has (the `mise` tasks, the demos), so the page describes the tools as built plus what is still only designed, and agents building later slices use the skills as they go. The app contract is Decision 9 (i) and the spec's [Runtime isolation](../spec/phase-5-runtime-and-polish.md#runtime-isolation) section: `Application` carries no isolation, a host is a `@MainActor` shell over one nonisolated step that takes `isolated any Actor`, and isolation mistakes are proved by compile-fail fixtures, never by `assumeIsolated`. The harness's public names are the seven the stable-surface decision lists; every other declaration in it is `package` or internal. "Demo" is the word for Tessera's own per-component example apps; the CLI and the scenario header say `app`. Patterns to avoid. The Showcase (a monolithic demo app) and the archived batch attempts; porting review bundles or committed captures; a `wait` that sleeps; prints as the way to answer "why". @@ -102,7 +102,7 @@ Everything else is `package` or internal (the step and the registry's reads are Isolation is pinned by two compile-fail fixtures beside `Tests/CompileFailFixtures/FrameRegionEscape.swift`, run by a script beside `scripts/check-frame-region-ownership.sh` and called from `mise-tasks/quality/architecture` the same way (`swiftc -typecheck` against the debug modules, an expected-diagnostic substring, and a positive control for each, so a toolchain that stops diagnosing fails the check instead of passing). First, an actor host given a main-actor app: `Browser: @MainActor Application` stepped from an actor through `Step.run(…, isolation: self)`; expect "main actor-isolated conformance of 'Browser' to 'Application' cannot be used in nonisolated context" (Swift 6.4 adds "cannot be used in actor-isolated context", so match the shared prefix); the control is the same app on a `@MainActor` host. Second, a default-main-actor type adopted from another module without `@MainActor` on the conformance: a module compiled with `-default-isolation MainActor` declares `Journal`, and a second module, typechecked in plain Swift 6 mode (no `InferIsolatedConformances`, no default isolation, which the repository's shared `swiftSettings` would otherwise add), writes `extension Journal: Application {}`; expect "crosses into main actor-isolated code and can cause data races"; the control is `extension Journal: @MainActor Application {}`. No exit tests are needed, because no path carries an `assumeIsolated`. -Create `.agents/skills/tessera-devtools/SKILL.md` with what this slice makes usable (`tessera apps`, `tessera launch NAME`, a demo's headless snapshot test) and an `AGENTS.md` pointer to it; later slices extend it. +Create the two skills: `.agents/skills/tessera/SKILL.md`, the published skill for app authors, with what this slice makes usable (the `Application`, `ApplicationRegistry`, and `HarnessCLI.main(registry:)` wiring, `tessera apps`, `tessera launch NAME`), and `.agents/skills/tessera-contributing/SKILL.md`, the in-repo skill for contributors, with the `mise run dev:*` tasks, the demos, and a demo's headless snapshot test; and an `AGENTS.md` pointer to both. Later slices extend the `tessera` skill first and the contributor skill after it. Delivers: one demo steps through one driver, headless and live: `tessera launch NAME` runs it in a terminal and a snapshot test renders it identically through the in-memory device. Verify: registry tests cover lookup and duplicates (every reading entry point fails); a greedy demo fails `size mobile`; one demo's snapshot test is a few lines, and it is the test that renders a `ViewGraph` through `TerminalSession.draw` into an `InMemoryTerminalDevice` and asserts the bytes the device received, which restores the coverage the retired Showcase's `ShowcaseTerminalSessionTests` gave; a nonisolated demo steps on a per-test actor and on the main actor through the same `Step.run`; both compile-fail fixtures fail with their expected diagnostics (the second one only in plain Swift 6 mode: Slice 3 checked it in a scratch two-module package on Swift 6.3.3 and 6.4, where the line is an error without `InferIsolatedConformances`, builds with default main-actor isolation in the extension's module, and builds with `InferIsolatedConformances` on 6.4 but still errors on 6.3.3 for a type isolated only through its module's default isolation), their controls compile, and `mise run quality:architecture` runs them; the `.resize` rule is in the step's doc comment and Slice 9's routing tests pin it once `dispatch` can consume events. `DemoSupport` re-adds the `Tessera` product to the Examples manifest and so replaces `Examples/Sources/ApplicationConsumer`, the one-file guard Slice 3 added for `import Tessera` from outside the root package; delete it here. @@ -192,9 +192,9 @@ Delivers: a demo in a real terminal answers `text`, `tree focused`, and `key Tab ### 19 Docs, skill, and the conformance gate -Plan 033 Phase H. The final review and revision of the `tessera-devtools` skill that Slice 4 created and each tooling slice extended (the loop: `tessera launch` → `run` → read JSON → `shot` → `tree` → trace → `check` → fix the library, never the demo; never `--record` to make a failing run pass); `docs/guides/developer-tools.md` (the human how-to for Tessera contributors and app authors, linking [Developer tooling](../architecture/developer-tooling.md) for the design rather than repeating it); the app-author path from Decision 9 (an app declares `: Application` where the type is declared, or `extension MyApp: Application {}` (`: @MainActor Application` for a main-actor type) in a test or dev target; adds a dev executable whose body is `HarnessCLI.main(registry:)`; registers its apps with `@MainActor` factories; writes scenarios, runs them from `swift test` through `ScenarioRunner`, and runs `check` in its own CI; the guide shows the four adoption shapes of the spec's Runtime isolation section and says that an app's own hand-written loop must match the canonical step until it adopts `ApplicationRuntime`); [Developer tooling's Stability section](../architecture/developer-tooling.md#stability) checked against the shipped `Examples/Scenarios/README.md`, the CLI, and the report types; the spec's diagnostics paragraph checked against the amendments Slices 12 and 18 made; and the doc drift `docs/research/tui-devtools-prior-art.md` names fixed. `mise run ci:check` and CI run `conform` on `implemented` docs. +Plan 033 Phase H. The final review and revision of both skills that Slice 4 created and each tooling slice extended: the published `tessera` skill (the loop: `tessera launch` → `run` → read JSON → `shot` → `tree` → trace → `check` → fix the app, or report a Tessera bug and never work around it; never `--record` to make a failing run pass) and the in-repo `tessera-contributing` skill (the `mise` tasks, the demos, and fix the library, never the demo); how the `tessera` skill is distributed is settled with Decision 9 (iii); `docs/guides/developer-tools.md` (the human how-to for Tessera contributors and app authors, linking [Developer tooling](../architecture/developer-tooling.md) for the design rather than repeating it); the app-author path from Decision 9 (an app declares `: Application` where the type is declared, or `extension MyApp: Application {}` (`: @MainActor Application` for a main-actor type) in a test or dev target; adds a dev executable whose body is `HarnessCLI.main(registry:)`; registers its apps with `@MainActor` factories; writes scenarios, runs them from `swift test` through `ScenarioRunner`, and runs `check` in its own CI; the guide shows the four adoption shapes of the spec's Runtime isolation section and says that an app's own hand-written loop must match the canonical step until it adopts `ApplicationRuntime`); [Developer tooling's Stability section](../architecture/developer-tooling.md#stability) checked against the shipped `Examples/Scenarios/README.md`, the CLI, and the report types; the spec's diagnostics paragraph checked against the amendments Slices 12 and 18 made; and the doc drift `docs/research/tui-devtools-prior-art.md` names fixed. `mise run ci:check` and CI run `conform` on `implemented` docs. -Delivers: a fresh agent given only the skill drives a demo, reads a failure, and produces a shot without reading source; an app outside this repository's package graph runs a scenario with `check` against a released or tagged Tessera. Verify: that agent run, recorded in this plan; the external app's CI run; CI fails on an unpinned `implemented` wireframe (probe and revert). +Delivers: a fresh agent given only the `tessera` skill drives an app, reads a failure, and produces a shot without reading source, with the `tessera-contributing` skill checked separately by a fresh agent that adds a demo and its snapshot test; an app outside this repository's package graph runs a scenario with `check` against a released or tagged Tessera. Verify: both agent runs, recorded in this plan; the external app's CI run; CI fails on an unpinned `implemented` wireframe (probe and revert). ### 20 Intro app and Phase 4 closure @@ -240,7 +240,7 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-21 Plan 032 Step 10.1 wires the dead `focusChanges` counter; the role and label hook lands before the first control; the environment clock is a `Clock` the harness can advance. (owner, plan 035 Step 6.1) - 2026-10-22 Flow and `ViewThatFits` (plan 032 Step 14.1) move before the controls, after focus and hit testing. (this plan, from the 2026-10-21 owner review note under plan 032 Step 13.2) Superseded 2026-10-23 by Decision 2 below. - 2026-10-22 Decision 1, the `mobile` viewport: (b), 40 columns at the component's natural height, with `mobileScreen` (40x16) declared per demo for screen-filling compositions; the harness derives natural height with the package nil-height probe and two-height detection, and a height-greedy demo fails `size mobile` naming `mobileScreen`. This confirms the 2026-10-10 catalog decision; `docs/design/templates/widget.md`, `docs/design/widgets/stepper.md`, and Slice 4 were brought into line, and plan 032 Step 8.2 stays as history in the cancelled plan. (owner, plan 035 Step 6.2) -- 2026-10-22 The tools' design lives in `docs/architecture/developer-tooling.md`, written before Slice 2 from plan 033 and the spikes, without plan or slice references; each slice that touches a tool keeps it and the `tessera-devtools` skill current before the slice is checked off, and a tool that outgrows its section moves to its own page under `docs/architecture/developer-tooling/`. The skill is created in Slice 4 so the agents building later slices use it, and Slice 19 is its final review. Demos show their component working (interactive, every variable style, extra controls allowed for complex components) rather than the archived "teach rather than dump" bar. (owner) +- 2026-10-22 The tools' design lives in `docs/architecture/developer-tooling.md`, written before Slice 2 from plan 033 and the spikes, without plan or slice references; each slice that touches a tool keeps it and the `tessera-devtools` skill current before the slice is checked off, and a tool that outgrows its section moves to its own page under `docs/architecture/developer-tooling/`. The skill is created in Slice 4 so the agents building later slices use it, and Slice 19 is its final review. Superseded 2026-10-24 by the skill split below. Demos show their component working (interactive, every variable style, extra controls allowed for complex components) rather than the archived "teach rather than dump" bar. (owner) - 2026-10-22 Decision 3, live inspection: (b). Amend the spec so a dev runner outside `TesseraCore` may expose an opt-in, local, same-user endpoint that serializes the harness projection and injects input through the normal step path; `TesseraCore` still serializes nothing. Slice 18 is no longer optional. (owner) - 2026-10-22 Decision 4, `role` and `label`: (b) with (a) as the fallback. Library controls set optional, value-typed fields through an internal hook (spec amendment in Slice 12); nodes without them derive role from `type` and label from screen text. No public label modifier in 1.0; the fields are shaped for a later accessibility or Tern Surface Protocol export, which the owner wants eventually so a Tessera app can render natively in Tern or a similar host. (owner) - 2026-10-22 Decision 7, Windows: the scenario corpus is required on Windows from Slice 5, with LF goldens through `.gitattributes`. If a Windows toolchain bug ever blocks it, that leg drops to non-blocking for that case and the reason is recorded here. (owner) @@ -274,6 +274,7 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-24 The driver does not reconcile terminal requirements after a draw (owner to weigh). The review loop's `ApplicationDriver` applied `graph.terminalRequirements` (mouse tracking, the keyboard protocol, focus and paste reporting) to the session after each frame; nothing at HEAD produces a requirement, and `dispatch` routes nothing, so the port stops at the step order and the update policy. Slices 9 and 10 add the reconciliation when focus and pointer input exist, in the one `Driver` both hosts share. (this plan, Slice 4 run) - 2026-10-24 The registry and demo shapes. The two spike fixtures (`StackApp`, `WorkspaceApp`) moved out of the library into `Tests/TesseraHarnessTests/Fixtures.swift` as `Application` types, with a greedy fixture, counters of each adoption shape, and a still app, so `TesseraHarness` no longer imports `TesseraLayout` or `TesseraWidgets` and its allowlist row dropped them. `Demo` requires `name`, `minimum`, `viewports`, `fixtures`, and `init()` with no defaults; a `DemoFixture` is a name and a main-actor factory that inherits the demo's minimum and viewports; `Demos.registry()` is the one list. The counter (32x5 minimum, `desktop` and `mobile`) quits on `q` through `isQuitKey`, which compares code, modifiers, and kind, and has one fixture (`counter-high`). `ActorHost` is the per-test actor shell; its initializer is `async`, because a synchronous actor initializer is nonisolated and cannot pass `self` as the step's isolation. The driver, the three hosts, the natural-height probe, the headless device, and the stream shim are internal rather than `package` (critic review of PR #65, F2): nothing outside `TesseraHarness` needs them, since the Examples tests go through `withHeadlessHost` and the `@testable` import reaches internal declarations anyway; the step, the registry's reads, `ScenarioHost`, and `withHeadlessHost` are `package`. If a later slice needs a host from another target, widening it is one keyword. (this plan, Slice 4 run) - 2026-10-24 The Examples tests run in CI from this slice (critic review of PR #65, F4). `ci.yml` gained a `Test Examples` step after `Test` (`swift test --package-path Examples`, on every leg), and `ci:build-test` the same line, so the counter's snapshot tests and the quit-key tests cannot regress unseen; Slice 5 adds the scenario corpus run to it. (this plan, Slice 4 run) +- 2026-10-24 The `tessera-devtools` skill is split in two (owner, Q13 option (a)). `.agents/skills/tessera/` is the published skill for app authors: the `tessera` CLI and `TesseraHarness` as an app author sees them, with one `| tessera | Does |` table, no repository paths, `mise`, demos, `DemoSupport`, or `@testable` API, and the rule that a Tessera bug is reported and not worked around. `.agents/skills/tessera-contributing/` is the in-repo contributor skill: it opens by saying everything in `tessera` applies, then adds a `| mise | tessera | Does |` table (`mise run dev:*` builds and runs the `tessera` executable), the demos and `DemoSupport`, adding a demo with its `withHeadlessHost` snapshot test, and the rule that the library is fixed with a regression test and a demo is never bent. Both skills describe only what exists, written as the finished current skill, with no future work in them. The published skill sits beside the repository's other skills so agents working here find it, and it links to nothing outside its own directory, so publishing it later is a copy of that directory; there is no symlink, because Windows checkouts handle them badly. `tessera-contributing` is named for who it serves (the repository has a `CONTRIBUTING.md`), where `tessera-devtools` named the tools and would now sit confusingly beside `tessera`. How the `tessera` skill is distributed is settled with Decision 9 (iii). This supersedes the skill sentences of the 2026-10-22 entry on the tools' design. (owner) - 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 @@ -306,7 +307,7 @@ Rules for an agent running slices of this plan without the owner. - `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. +- A keyboard-driven live proof needs a pty: `expect` with `spawn -noecho sh -c "stty rows 24 cols 80; exec …"` gives the app a window size before it starts, and `log_file -a` records the bytes while `log_user 0` hides them. Replaying the log through a small cursor-addressing interpreter (cursor position, clear, printable text) gives the screens. The recipe is in the `tessera` skill. ## Outcome From 8a8ccc18a2d1b4279ac5e316e8c1f47f11dbe76b Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 14:01:18 -0400 Subject: [PATCH 35/55] fix(harness): keep tessera help free of repository paths --- CHANGELOG.md | 2 +- Examples/Scenarios/README.md | 2 +- .../TesseraHarness/HarnessCLI+Scenarios.swift | 49 +------ Sources/TesseraHarness/HarnessCLI.swift | 8 +- Sources/TesseraHarness/HelpText.swift | 28 ++-- .../TesseraHarness/Scenarios/Grammar.swift | 53 ++++---- .../Scenarios/ScenarioRunner.swift | 6 +- .../HarnessCLIScenarioTests.swift | 120 ------------------ .../TesseraHarnessTests/HarnessCLITests.swift | 63 ++++----- Tests/TesseraHarnessTests/ScenarioTests.swift | 38 +++--- docs/architecture/developer-tooling.md | 2 +- ...hase-4-view-layer-and-developer-tooling.md | 3 +- mise-tasks/dev/check | 9 -- mise-tasks/dev/replay | 9 -- 14 files changed, 89 insertions(+), 303 deletions(-) delete mode 100755 mise-tasks/dev/check delete mode 100755 mise-tasks/dev/replay diff --git a/CHANGELOG.md b/CHANGELOG.md index 5438941..04e4e8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added the `tessera` agent skill for app authors (writing an `Application`, registering it, wiring the executable over `HarnessCLI.main(registry:)`, and `tessera apps` and `tessera launch`) and the `tessera-contributing` skill for contributors (the `mise run dev:*` tasks, the demos, and their snapshot tests), with a pointer from `AGENTS.md`. - Added 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 the scenario language and its runner. `Examples/Scenarios/README.md` is the grammar (`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), with a table of the forms it names but cannot honour yet (`wait`, `settle`, `focus`, `mouse`, `check`, `expect cell`, …), each a parse error that says what it waits for. A header-binding, fail-fast parser reports `FILE:LINE: error:` and reads CRLF files; a runner continues after a failed expectation; `shot` compares a text golden beside the scenario and fails on drift unless `--record` writes it. `TesseraHarness` gains `ScenarioRunner`, `ScenarioReport`, and `ScenarioFailure` as its public run entry point, and answers a session's colour queries (OSC 10, 11, and 4, and DA1) from the scenario's `term` header. -- Added `tessera run PATH...` (with `--record`, `--repeat N`, `--timing`), `tessera shot NAME [SIZE]`, and `tessera tree NAME [SIZE]` to `HarnessCLI`, each printing text on a terminal and one JSON object otherwise, with exit codes 0 pass, 1 an expectation failed, 2 a usage or parse error, and 3 a harness error; `tessera check` and `tessera replay` are named and refuse with "not yet available" until the projection and the trace exist. `help COMMAND` prints real text, and `mise run dev:run`, `dev:shot`, `dev:tree`, `dev:check`, `dev:replay`, and `dev:help` mirror the commands. +- Added `tessera run PATH...` (with `--record`, `--repeat N`, `--timing`), `tessera shot NAME [SIZE]`, and `tessera tree NAME [SIZE]` to `HarnessCLI`, each printing text on a terminal and one JSON object otherwise, with exit codes 0 pass, 1 an expectation failed, 2 a usage or parse error, and 3 a harness error. `help COMMAND` prints real text, and `mise run dev:run`, `dev:shot`, `dev:tree`, and `dev:help` mirror the commands. - Added the scenario corpus, `Examples/Scenarios`, ported from the HeadlessRunner spike with a counter scenario, its text goldens, and the `stack` and `workspace` demos it runs against, and made CI run `tessera run Examples/Scenarios` on every leg of the test matrix, Windows included, with goldens checked out with LF. - 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. diff --git a/Examples/Scenarios/README.md b/Examples/Scenarios/README.md index 816edc6..ab18810 100644 --- a/Examples/Scenarios/README.md +++ b/Examples/Scenarios/README.md @@ -143,7 +143,7 @@ When several apply, the highest wins. `--repeat N` runs each scenario N times an ## Not yet expressible -These forms are part of the language's design and the harness cannot honour them yet. Each one is a parse error that says what it waits for, never a statement that is skipped. A scenario that uses one fails loudly on a harness that does not have it. +These forms are part of the language's design and the harness cannot honour them yet. Each one is a parse error that lists the accepted statements, never a statement that is skipped. A scenario that uses one fails loudly on a harness that does not have it. | Form | Waits for | | --- | --- | diff --git a/Sources/TesseraHarness/HarnessCLI+Scenarios.swift b/Sources/TesseraHarness/HarnessCLI+Scenarios.swift index 49f18e3..2964b80 100644 --- a/Sources/TesseraHarness/HarnessCLI+Scenarios.swift +++ b/Sources/TesseraHarness/HarnessCLI+Scenarios.swift @@ -172,7 +172,7 @@ extension HarnessCLI { : picture.tree.map { $0 + "\n" }.joined()) } - /// `NAME [SIZE]`, the words `shot`, `tree`, and `check` take. + /// `NAME [SIZE]`, the words `shot` and `tree` take. private static func appAndSize(_ arguments: [String], command: String) throws(CommandArguments.Failure) -> (name: String, viewport: Viewport) { @@ -187,51 +187,4 @@ extension HarnessCLI { } return (parsed.positional[0], viewport) } - - // MARK: check and replay - - static func check( - _ arguments: [String], registry: ApplicationRegistry, json: Bool - ) -> CommandOutput { - let target: (name: String, viewport: Viewport) - do { - target = try appAndSize(arguments, command: "check") - } catch { - return usage(error.message, command: "check", json: json) - } - // The name is checked as `shot` checks it, so the refusal below is the only thing that - // changes when the rules exist. - do { - _ = try registry.entry(named: target.name) - } catch { - return registryFailure(error, 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], registry: ApplicationRegistry, 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, let name = parsed.values["--app"] else { - return usage("usage: replay FILE --app NAME", command: "replay", json: json) - } - do { - _ = try registry.entry(named: name) - } catch { - return registryFailure(error, 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 4797e1b..6f14a32 100644 --- a/Sources/TesseraHarness/HarnessCLI.swift +++ b/Sources/TesseraHarness/HarnessCLI.swift @@ -14,7 +14,7 @@ import TesseraTerminal /// The commands are `apps` (list the registry), `launch NAME` (run one application in this /// 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]`. +/// node tree), 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 @@ -94,7 +94,7 @@ extension HarnessCLI { } static let commands = [ - "apps", "launch", "run", "shot", "tree", "check", "replay", "help", + "apps", "launch", "run", "shot", "tree", "help", ] /// Runs one command and returns what it printed, without touching the process. @@ -143,10 +143,6 @@ extension HarnessCLI { 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, registry: registry, json: asJSON) - case "replay": - return replay(rest, registry: registry, json: asJSON) case "help": return help(rest, json: asJSON) default: diff --git a/Sources/TesseraHarness/HelpText.swift b/Sources/TesseraHarness/HelpText.swift index 223d958..e45622a 100644 --- a/Sources/TesseraHarness/HelpText.swift +++ b/Sources/TesseraHarness/HelpText.swift @@ -9,8 +9,6 @@ enum HelpText { 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 @@ -40,8 +38,14 @@ enum HelpText { 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. + a usage error. Each scenario starts a fresh application. + + A scenario is a text file with one statement per line. The header statements are + `app NAME`, `size SIZE`, and `term colors=... theme=...`. The input statements are + `resize WxH`, `key NAME`, `type "text"`, `paste "text"`, and `bytes "..."`. `shot NAME` + compares the screen with a golden file and `tree` adds the node tree to the report. The + assertions are `expect text [ROW] "text"`, `expect screen < 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)" + "`\(form)` is not a statement a scenario can use; accepted: \(acceptedStatements)" } } diff --git a/Sources/TesseraHarness/Scenarios/ScenarioRunner.swift b/Sources/TesseraHarness/Scenarios/ScenarioRunner.swift index 06f2f09..0017d72 100644 --- a/Sources/TesseraHarness/Scenarios/ScenarioRunner.swift +++ b/Sources/TesseraHarness/Scenarios/ScenarioRunner.swift @@ -2,9 +2,9 @@ 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: +/// A scenario is a text file of statements, one per line; `tessera help run` lists them. 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 diff --git a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift index eca8b6d..262b240 100644 --- a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift @@ -514,126 +514,6 @@ struct HarnessCLIScenarioTests { } } - // 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") - - assertInlineSnapshot(of: [check, replay], as: .customDump) { - #""" - [ - [0]: CommandOutput( - exitCode: 2, - stdout: """ - {"command":"check","exitCode":2,"message":"`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"} - - """, - stderr: "" - ), - [1]: CommandOutput( - exitCode: 2, - stdout: """ - {"command":"replay","exitCode":2,"message":"`replay` is not yet available: it reruns a recorded trace, and the harness records no traces yet"} - - """, - stderr: "" - ) - ] - """# - } - } - - @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") - - assertInlineSnapshot(of: [noApp, badSize, noFile, noName], as: .customDump) { - #""" - [ - [0]: CommandOutput( - exitCode: 2, - stdout: "", - stderr: """ - error: usage: check NAME [SIZE] - - """ - ), - [1]: CommandOutput( - exitCode: 2, - stdout: "", - stderr: """ - error: bad size `huge`; expected WxH, desktop, mobile, or mobileScreen - - """ - ), - [2]: CommandOutput( - exitCode: 2, - stdout: "", - stderr: """ - error: usage: replay FILE --app NAME - - """ - ), - [3]: CommandOutput( - exitCode: 2, - stdout: "", - stderr: """ - error: usage: replay FILE --app NAME - - """ - ) - ] - """# - } - } - - @Test - func `check and replay name an unknown app before they refuse`() async { - var duplicated = Fixtures.registry() - duplicated.register("stack", viewports: [.desktop]) { StackApp() } - - let check = await run("check", "ghost", "--pretty") - let replay = await run("replay", "run.trace", "--app", "ghost", "--pretty") - let duplicate = await run("check", "stack", "--pretty", registry: duplicated) - - assertInlineSnapshot(of: [check, replay, duplicate], as: .customDump) { - #""" - [ - [0]: CommandOutput( - exitCode: 2, - stdout: "", - stderr: """ - error: unknown app `ghost`; accepted: counter, event-log, greedy, main-actor-counter, observed-counter, stack, workspace - - """ - ), - [1]: CommandOutput( - exitCode: 2, - stdout: "", - stderr: """ - error: unknown app `ghost`; accepted: counter, event-log, greedy, main-actor-counter, observed-counter, stack, workspace - - """ - ), - [2]: CommandOutput( - exitCode: 3, - stdout: "", - stderr: """ - error: applications registered more than once: `stack` - - """ - ) - ] - """# - } - } - // MARK: help @Test diff --git a/Tests/TesseraHarnessTests/HarnessCLITests.swift b/Tests/TesseraHarnessTests/HarnessCLITests.swift index 11cbc4d..fb38698 100644 --- a/Tests/TesseraHarnessTests/HarnessCLITests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLITests.swift @@ -265,7 +265,7 @@ struct HarnessCLITests { CommandOutput( exitCode: 2, stdout: """ - {"command":null,"exitCode":2,"message":"unknown command `jump`; accepted: apps, launch, run, shot, tree, check, replay, help"} + {"command":null,"exitCode":2,"message":"unknown command `jump`; accepted: apps, launch, run, shot, tree, help"} """, stderr: "" @@ -284,7 +284,7 @@ struct HarnessCLITests { exitCode: 2, stdout: "", stderr: """ - error: unknown command `jump`; accepted: apps, launch, run, shot, tree, check, replay, help + error: unknown command `jump`; accepted: apps, launch, run, shot, tree, help """ ) @@ -302,7 +302,7 @@ struct HarnessCLITests { exitCode: 2, stdout: "", stderr: """ - error: a command is required; accepted: apps, launch, run, shot, tree, check, replay, help + error: a command is required; accepted: apps, launch, run, shot, tree, help """ ) @@ -331,8 +331,6 @@ struct HarnessCLITests { 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 @@ -375,8 +373,14 @@ struct HarnessCLITests { 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. + a usage error. Each scenario starts a fresh application. + + A scenario is a text file with one statement per line. The header statements are + `app NAME`, `size SIZE`, and `term colors=... theme=...`. The input statements are + `resize WxH`, `key NAME`, `type "text"`, `paste "text"`, and `bytes "..."`. `shot NAME` + compares the screen with a golden file and `tree` adds the node tree to the report. The + assertions are `expect text [ROW] "text"`, `expect screen <] ..."} """, stderr: "" @@ -261,7 +263,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: Unknown option '--frobnicate'; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: Unknown option '--frobnicate'; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... """ ), @@ -269,7 +271,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: Missing value for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: Missing value for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... """ ), @@ -277,7 +279,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: `--repeat` needs a whole number of at least 1, got `0`; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: `--repeat` needs a whole number of at least 1, got `0`; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... """ ), @@ -285,7 +287,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: The value 'many' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: The value 'many' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... """ ) @@ -306,7 +308,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: The value 'x.scn' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: The value 'x.scn' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... """ ) @@ -324,7 +326,7 @@ struct HarnessCLIScenarioTests { #expect(recorded.exitCode == 0) let report = try #require(try reports(recorded).first) - #expect((report["recorded"] as? [[String: Any]])?.count == 1) + #expect((report["recorded"] as? [[String: Any]])?.count == 2) #expect((report["seconds"] as? [Double])?.count == 2) #expect(checked.exitCode == 0) #expect(try reports(checked)[0]["seconds"] == nil) @@ -505,6 +507,617 @@ struct HarnessCLIScenarioTests { } } + // MARK: check + + private func projectionRun(_ arguments: String...) async -> CommandOutput { + await HarnessCLI.run( + arguments: arguments, registry: ProjectionFixtures.registry(), + inputIsTerminal: false, + outputIsTerminal: false, configuration: .default) + } + + @Test + func `check runs every rule over the app at a size and a clean app passes`() + async throws + { + let output = await projectionRun("check", "layers", "mobile") + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + {"app":"layers","command":"check","exitCode":0,"rules":["no-dirty-after-pass","frame-inside-clip","measured-within-proposal","siblings-disjoint","requirements-aggregate"],"size":"40x6","violations":[]} + + """, + stderr: "" + ) + """# + } + } + + @Test + func `check exits 1 when a rule that is an error is violated and lists it`() + async throws + { + let output = await projectionRun("check", "overlap") + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 1, + stdout: """ + {"app":"overlap","command":"check","exitCode":1,"rules":["no-dirty-after-pass","frame-inside-clip","measured-within-proposal","siblings-disjoint","requirements-aggregate"],"size":"80x24","violations":[{"detail":"shows 0,0,6x1, which overlaps sibling root/index(0)/index(0) showing 0,0,5x1 under Overlap","node":"root/index(0)/index(1)","rule":"siblings-disjoint","severity":"error"}]} + + """, + stderr: "" + ) + """# + } + } + + @Test + func `check lists a warning with its errors and a text form that says PASS or FAIL`() + async + { + let json = await projectionRun("check", "overflow") + let text = await projectionRun("check", "overflow", "--pretty") + let passing = await projectionRun("check", "layers", "--pretty") + + assertInlineSnapshot(of: [json, text, passing], as: .customDump) { + #""" + [ + [0]: CommandOutput( + exitCode: 1, + stdout: """ + {"app":"overflow","command":"check","exitCode":1,"rules":["no-dirty-after-pass","frame-inside-clip","measured-within-proposal","siblings-disjoint","requirements-aggregate"],"size":"80x24","violations":[{"detail":"frame 0,0,12x1 is clipped to 0,0,4x1 (visible text `wide`)","node":"root/index(0)/index(0)/index(0)","rule":"frame-inside-clip","severity":"warning"},{"detail":"measured 12 columns where 4 were offered","node":"root/index(0)/index(0)/index(0)","rule":"measured-within-proposal","severity":"error"}]} + + """, + stderr: "" + ), + [1]: CommandOutput( + exitCode: 1, + stdout: """ + check overflow at 80x24 against 5 rules: no-dirty-after-pass, frame-inside-clip, measured-within-proposal, siblings-disjoint, requirements-aggregate + warning frame-inside-clip root/index(0)/index(0)/index(0): frame 0,0,12x1 is clipped to 0,0,4x1 (visible text `wide`) + error measured-within-proposal root/index(0)/index(0)/index(0): measured 12 columns where 4 were offered + FAIL overflow: 1 error, 1 warning + + """, + stderr: "" + ), + [2]: CommandOutput( + exitCode: 0, + stdout: """ + check layers at 80x24 against 5 rules: no-dirty-after-pass, frame-inside-clip, measured-within-proposal, siblings-disjoint, requirements-aggregate + PASS layers + + """, + stderr: "" + ) + ] + """# + } + } + + @Test + func `check validates its arguments and names an unknown app`() async { + var duplicated = Fixtures.registry() + duplicated.register("stack", viewports: [.desktop]) { StackApp() } + + let noApp = await run("check", "--pretty") + let badSize = await run("check", "stack", "huge", "--pretty") + let unknown = await run("check", "ghost", "--pretty") + let duplicate = await run("check", "stack", "--pretty", registry: duplicated) + let greedy = await run("check", "greedy", "mobile", "--pretty") + + assertInlineSnapshot( + of: [noApp, badSize, unknown, duplicate, greedy], as: .customDump + ) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: Missing expected argument ''; usage: tessera check [--json] [--pretty] [] + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: bad size `huge`; expected WxH, desktop, mobile, or mobileScreen; usage: tessera check [--json] [--pretty] [] + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown app `ghost`; accepted: counter, event-log, greedy, main-actor-counter, observed-counter, stack, workspace + + """ + ), + [3]: CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: applications registered more than once: `stack` + + """ + ), + [4]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `greedy` has no natural height: it measured 500 rows when offered 500 and 1000 when offered 1000. Use `size mobileScreen` (40x16) or an explicit WxH + + """ + ) + ] + """# + } + } + + @Test + func `run --pretty shows a bare check's warnings under the scenario's line`() + async throws + { + let scratch = try ScratchDirectory() + try scratch.write("w.scn", "app overflow\ncheck\n") + + let output = await projectionRun("run", scratch.path, "--pretty") + + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 1, + stdout: """ + FAIL /w.scn + line 2: warning frame-inside-clip root/index(0)/index(0)/index(0): frame 0,0,12x1 is clipped to 0,0,4x1 (visible text `wide`) + line 2: check + measured-within-proposal at root/index(0)/index(0)/index(0): measured 12 columns where 4 were offered + screen: + 0| wide + 1| ok + 1 scenario, 0 passed, 1 failed + + """, + stderr: "" + ) + """# + } + } + + @Test + func `a scenario that passes with warnings prints them under its PASS line`() { + let report = ScenarioReport( + scenario: "flex.scn", failures: [], + observations: [ + ScenarioObservation( + line: 7, verb: "check", name: nil, + lines: [ + "ran no-dirty-after-pass, frame-inside-clip", + "warning frame-inside-clip root/a: shows 0,0,9x1", + ]), + ScenarioObservation( + line: 9, verb: "check", name: nil, lines: ["ran no-dirty-after-pass"]), + ScenarioObservation( + line: 3, verb: "shot", name: "a", lines: ["warning: not a check"]), + ]) + + assertInlineSnapshot(of: report.description, as: .lines) { + """ + PASS flex.scn + line 7: warning frame-inside-clip root/a: shows 0,0,9x1 + """ + } + assertInlineSnapshot(of: report.json, as: .lines) { + """ + {"failures":[],"observations":[{"line":7,"lines":["ran no-dirty-after-pass, frame-inside-clip","warning frame-inside-clip root/a: shows 0,0,9x1"],"verb":"check"},{"line":9,"lines":["ran no-dirty-after-pass"],"verb":"check"},{"line":3,"lines":["warning: not a check"],"name":"a","verb":"shot"}],"passed":true,"scenario":"flex.scn"} + """ + } + } + + @Test + func `an empty path, name, or flag value is a usage error and nothing runs`() + async throws + { + let scratch = try ScratchDirectory() + try scratch.write("a.scn", passing) + let out = scratch.appending("traces") + + let trace = await run("run", scratch.path, "--trace", "", "--pretty") + let path = await run("run", "", "--pretty") + let inside = await run("run", scratch.path, "", "--pretty") + let shot = await run("shot", "", "--pretty") + let tree = await run("tree", "", "--pretty") + let check = await run("check", "", "--pretty") + let file = await run("replay", "", "--app", "stack", "--pretty") + let app = await run("replay", "t.jsonl", "--app", "", "--pretty") + let json = await run("run", scratch.path, "--trace", "") + + assertInlineSnapshot( + of: scratch.dump([trace, path, inside, shot, tree, check, file, app, json]), + as: .lines + ) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--trace` needs a value that is not empty; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... + + """ + ), + [3]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name; usage: tessera shot [--json] [--pretty] [] + + """ + ), + [4]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name; usage: tessera tree [--json] [--pretty] [] + + """ + ), + [5]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name; usage: tessera check [--json] [--pretty] [] + + """ + ), + [6]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: an empty argument is not a path or an application name; usage: tessera replay [--json] [--pretty] --app + + """ + ), + [7]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--app` needs a value that is not empty; usage: tessera replay [--json] [--pretty] --app + + """ + ), + [8]: CommandOutput( + exitCode: 2, + stdout: """ + {"command":"run","exitCode":2,"message":"`--trace` needs a value that is not empty; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ..."} + + """, + stderr: "" + ) + ] + """# + } + #expect(!FileManager.default.fileExists(atPath: out)) + } + + @Test + func `the help for replay spells its required flag and the overview lists it`() async { + let overview = await run("help") + let help = await run("help", "replay") + + #expect(overview.exitCode == 0) + #expect(overview.stdout.contains(" replay ")) + #expect(overview.stdout.contains(" check ")) + #expect( + help.stdout.contains( + "USAGE: tessera replay [--json] [--pretty] --app ")) + } + + // MARK: trace and replay + + @Test + func `run --trace writes one trace file per scenario under the directory`() async throws + { + let scratch = try ScratchDirectory() + try scratch.write("a.scn", traced) + try scratch.write("nested/b.scn", traced) + let out = scratch.appending("traces") + + let output = await run("run", scratch.path, "--trace", out, "--pretty") + + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + PASS /a.scn + PASS /nested/b.scn + 2 scenarios, 2 passed, 0 failed + + """, + stderr: "" + ) + """# + } + let files = [ + TraceFile.path(forScenario: scratch.appending("a.scn"), in: out), + TraceFile.path(forScenario: scratch.appending("nested/b.scn"), in: out), + ] + for file in files { + let text = try #require( + FileManager.default.contents(atPath: file).flatMap { + String(validating: $0, as: UTF8.self) + }, "no trace at \(file)") + let records = try TraceFile.records(in: text) + expectNoDifference(records.map(\.seq), [0, 1, 2, 3, 4, 5]) + expectNoDifference( + records[4].event, TraceEvent(.resize(TerminalSize(columns: 30, rows: 8)))) + } + } + + @Test + func `replay runs a recorded trace again and passes against the app it came from`() + async throws + { + let scratch = try ScratchDirectory() + let scenario = try scratch.write("a.scn", traced) + let out = scratch.appending("traces") + _ = await run("run", scenario, "--trace", out, "--pretty") + let file = TraceFile.path(forScenario: scenario, in: out) + + let output = await run("replay", file, "--app", "stack") + + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + {"command":"replay","exitCode":0,"scenarios":[{"failures":[],"observations":[{"line":0,"lines":["Stack demo","──────────────────────────────","Name: Ro","left right","──────────────────────────────","30 columns"],"verb":"replay"}],"passed":true,"scenario":"/traces/a.trace.jsonl"}]} + + """, + stderr: "" + ) + """# + } + } + + @Test + func `replay against a different app exits 1 and names the step that differs`() + async throws + { + let scratch = try ScratchDirectory() + let scenario = try scratch.write("a.scn", traced) + let out = scratch.appending("traces") + _ = await run("run", scenario, "--trace", out, "--pretty") + let file = TraceFile.path(forScenario: scenario, in: out) + var other = ApplicationRegistry() + other.register("stack", viewports: [.desktop]) { GreedyApp() } + + let output = await run("replay", file, "--app", "stack", "--pretty", registry: other) + + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 1, + stdout: """ + FAIL /traces/a.trace.jsonl + line 1: step 0: resize 40x10 + the changed nodes differ from the recording + expected: {"added":["root","root/index(0)","root/index(0)/index(0)","root/index(0)/index(1)","root/index(0)/index(2)","root/index(0)/index(2)/index(0)","root/index(0)/index(2)/index(1)","root/index(0)/index(3)","root/index(0)/index(3)/index(0)","root/index(0)/index(3)/index(1)","root/index(0)/index(3)/index(2)","root/index(0)/index(4)","root/index(0)/index(5)"],"changed":[],"removed":[]} + actual: {"added":["root","root/index(0)","root/index(0)/index(0)","root/index(0)/index(1)","root/index(0)/index(2)"],"changed":[],"removed":[]} + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| + 8| + 9| footer + line 2: step 1: key R + the changed rows differ from the recording + expected: [2] + actual: [] + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| + 8| + 9| footer + line 3: step 2: key o + the changed nodes differ from the recording + expected: {"added":[],"changed":["root/index(0)/index(2)","root/index(0)/index(2)/index(1)"],"removed":[]} + actual: {"added":[],"changed":[],"removed":[]} + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| + 8| + 9| footer + line 4: step 3: key b + the changed nodes differ from the recording + expected: {"added":[],"changed":["root/index(0)/index(2)","root/index(0)/index(2)/index(1)"],"removed":[]} + actual: {"added":[],"changed":[],"removed":[]} + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| + 8| + 9| footer + line 5: step 4: resize 30x8 + the changed nodes differ from the recording + expected: {"added":[],"changed":["root","root/index(0)","root/index(0)/index(0)","root/index(0)/index(1)","root/index(0)/index(2)","root/index(0)/index(3)","root/index(0)/index(3)/index(1)","root/index(0)/index(3)/index(2)","root/index(0)/index(4)","root/index(0)/index(5)"],"removed":[]} + actual: {"added":[],"changed":["root","root/index(0)","root/index(0)/index(0)","root/index(0)/index(1)","root/index(0)/index(2)"],"removed":[]} + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| footer + line 6: step 5: key Backspace + the changed nodes differ from the recording + expected: {"added":[],"changed":["root/index(0)/index(2)","root/index(0)/index(2)/index(1)"],"removed":[]} + actual: {"added":[],"changed":[],"removed":[]} + screen: + 0| Greedy + 1| + 2| + 3| + 4| + 5| + 6| + 7| footer + + """, + stderr: "" + ) + """# + } + } + + @Test + func `replay validates its arguments, its file, and its app`() async throws { + let scratch = try ScratchDirectory() + let notTrace = try scratch.write("nope.jsonl", "not json\n") + let binary = scratch.appending("bin.jsonl") + try Data([0xff, 0xfe, 0x00]).write(to: URL(fileURLWithPath: binary)) + + let noFile = await run("replay", "--app", "stack", "--pretty") + let noName = await run("replay", "run.trace", "--pretty") + let missing = await run( + "replay", scratch.appending("none.jsonl"), "--app", "stack", "--pretty") + let notUTF8 = await run("replay", binary, "--app", "stack", "--pretty") + let notRecords = await run("replay", notTrace, "--app", "stack", "--pretty") + let unknown = await run("replay", "run.trace", "--app", "ghost", "--pretty") + + assertInlineSnapshot( + of: scratch.dump([noFile, noName, missing, notUTF8, notRecords, unknown]), + as: .lines + ) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: Missing expected argument ''; usage: tessera replay [--json] [--pretty] --app + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: Missing expected argument '--app '; usage: tessera replay [--json] [--pretty] --app + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: cannot read the trace `/none.jsonl` + + """ + ), + [3]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: the trace `/bin.jsonl` is not UTF-8 text + + """ + ), + [4]: CommandOutput( + exitCode: 2, + stdout: """ + FAIL /nope.jsonl + /nope.jsonl:1: error: this line is not a trace record (not valid JSON) + + """, + stderr: "" + ), + [5]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: unknown app `ghost`; accepted: counter, event-log, greedy, main-actor-counter, observed-counter, stack, workspace + + """ + ) + ] + """# + } + } + + @Test + func `launch with a trace file it cannot create exits 3 before the terminal changes`() + async throws + { + let scratch = try ScratchDirectory() + + let output = await HarnessCLI.run( + arguments: ["launch", "stack", "--pretty"], registry: Fixtures.registry(), + inputIsTerminal: true, outputIsTerminal: true, configuration: .default, + environment: ["TESSERA_TRACE": scratch.path]) + + assertInlineSnapshot(of: scratch.dump(output), as: .lines) { + #""" + CommandOutput( + exitCode: 3, + stdout: "", + stderr: """ + error: cannot create the trace file ``: the file could not be created + + """ + ) + """# + } + } + // MARK: help @Test @@ -518,11 +1131,15 @@ struct HarnessCLIScenarioTests { #expect(help.stdout.count > 60, "\(command)") } let runHelp = await run("help", "run") - for flag in ["--record", "--repeat ", "--timing", "--json", "--pretty"] { + for flag in [ + "--record", "--repeat ", "--timing", "--trace ", "--json", "--pretty", + ] { #expect(runHelp.stdout.contains(flag), "\(flag)") } + // The help wraps at the terminal's width, so a phrase can break across lines. + let flat = runHelp.stdout.replacingOccurrences(of: "\n", with: " ") for field in [#""change": "created" or "updated""#, #""line" 0"#, #""--repeat N""#] { - #expect(runHelp.stdout.contains(field), "\(field)") + #expect(flat.contains(field), "\(field)") } } } diff --git a/Tests/TesseraHarnessTests/HarnessCLITests.swift b/Tests/TesseraHarnessTests/HarnessCLITests.swift index 5496ba5..92b2081 100644 --- a/Tests/TesseraHarnessTests/HarnessCLITests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLITests.swift @@ -267,7 +267,7 @@ struct HarnessCLITests { CommandOutput( exitCode: 2, stdout: """ - {"command":null,"exitCode":2,"message":"unknown command `jump`; accepted: apps, launch, run, shot, tree, help"} + {"command":null,"exitCode":2,"message":"unknown command `jump`; accepted: apps, launch, run, shot, tree, check, replay, help"} """, stderr: "" @@ -286,7 +286,7 @@ struct HarnessCLITests { exitCode: 2, stdout: "", stderr: """ - error: unknown command `jump`; accepted: apps, launch, run, shot, tree, help + error: unknown command `jump`; accepted: apps, launch, run, shot, tree, check, replay, help """ ) @@ -304,7 +304,7 @@ struct HarnessCLITests { exitCode: 2, stdout: "", stderr: """ - error: a command is required; accepted: apps, launch, run, shot, tree, help + error: a command is required; accepted: apps, launch, run, shot, tree, check, replay, help """ ) @@ -324,7 +324,7 @@ struct HarnessCLITests { [0]: CommandOutput( exitCode: 0, stdout: """ - OVERVIEW: List, launch, run, and picture the registered applications. + OVERVIEW: List, launch, run, picture, and check the registered applications. 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 @@ -349,6 +349,8 @@ struct HarnessCLITests { run Run scenario files, or directories of them. shot Print one application's screen as text. tree Print one application's node tree. + check Check one application against the invariant rules. + replay Run a recorded trace again against an application. See 'tessera help ' for detailed help. @@ -390,6 +392,11 @@ struct HarnessCLITests { Exits 2 for an unknown name, and 3 for a duplicate registration or when standard input or output is not a terminal. + When the environment variable TESSERA_TRACE names a file, the session writes + its trace there, one JSON record per step, so `replay` can run it again. A file + that cannot be created is exit 3, before the terminal changes mode. The trace + is not promised to stay as it is. + USAGE: tessera launch [--json] [--pretty] ARGUMENTS: @@ -455,7 +462,7 @@ struct HarnessCLITests { [0]: CommandOutput( exitCode: 2, stdout: """ - {"command":"help","exitCode":2,"message":"unknown command `ghost`; accepted: apps, launch, run, shot, tree, help"} + {"command":"help","exitCode":2,"message":"unknown command `ghost`; accepted: apps, launch, run, shot, tree, check, replay, help"} """, stderr: "" @@ -464,7 +471,7 @@ struct HarnessCLITests { exitCode: 2, stdout: "", stderr: """ - error: unknown command `ghost`; accepted: apps, launch, run, shot, tree, help + error: unknown command `ghost`; accepted: apps, launch, run, shot, tree, check, replay, help """ ) diff --git a/docs/architecture/developer-tooling.md b/docs/architecture/developer-tooling.md index 3842363..5fe6b14 100644 --- a/docs/architecture/developer-tooling.md +++ b/docs/architecture/developer-tooling.md @@ -193,9 +193,9 @@ Exit codes: 0 pass, 1 an expectation or check failed, 2 a usage or parse error, Built today: `apps`, `launch NAME`, `run PATH...`, `shot NAME [SIZE]`, `tree NAME [SIZE]`, `check NAME [SIZE]`, `replay FILE --app NAME`, and `help [COMMAND]`, with `--json` and `--pretty` forcing either form of any of them. `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. When `TESSERA_TRACE` names a file, `launch` creates it before the terminal changes mode (exit 3 if it cannot) and writes the session's trace into it as the steps happen. `run` takes `.scn` files and directories (every `.scn` under a directory, sorted, hidden entries skipped), runs each scenario against a fresh app, and prints one report per scenario: the human form on a terminal, and otherwise `{"command": "run", "exitCode", "scenarios": [REPORT...]}`, where a report is `{"failures", "observations", "passed", "scenario"}` with `recorded` when `--record` wrote goldens and `seconds` under `--timing`. A failure is `{"line", "statement", "verb", "message"}` with `expected`, `actual`, and `screen` when the statement compares values, and `rule` and `node` when a `check` rule did not hold, and its `verb` decides the exit code: a statement's own verb, `size`, or `repeat` is an expectation that failed (1), `parse` and `usage` are mistakes in the file or the command (2), and `harness` is a fault in the run (3); the highest of a run wins. A scenario that does not parse is not run and reports one `parse` failure. `--record` writes goldens, `--repeat N` runs each scenario N times and reports a `repeat` failure for any run whose report or trace differs from the first, `--timing` adds the seconds of each run, which are the only numbers that differ between two runs, and `--trace DIR` writes each scenario's trace under `DIR`, mirroring the scenario's path with `.trace.jsonl` added. `shot` prints the screen of one app at a size (`desktop`, `mobile`, `mobileScreen`, or `WxH`) as a golden would hold it, and `tree` prints its node tree, one line per node, with a container's layout algorithm in parentheses; both take the app as a positional name, answer a size the app cannot take with a usage error, and have JSON forms `{"app", "command", "exitCode", "rows" or "lines", "size"}`. `check` runs every rule over one app at a size with no input and prints `{"app", "command": "check", "exitCode", "rules", "size", "violations": [{"detail", "node", "rule", "severity"}]}`; an error is exit 1, a warning is listed and is not. `replay` runs a trace's events again against the named app and prints a run report for it, `{"command": "replay", "exitCode", "scenarios": [REPORT]}`: a step that differs from the recording is a `replay` failure (exit 1), and a trace that is not a trace, or that holds an event the grammar cannot say, is a usage error (exit 2). `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. `ScenarioRunner(registry:record:)` is the Swift entry point to `run`, one report per scenario, so an app's tests and its command line run the same scenarios the same way. -The commands and flags are declared with [swift-argument-parser](https://github.com/apple/swift-argument-parser), and only `TesseraHarness` depends on it (`scripts/check-package-boundaries.py` allowlists the one product), so the library, the runtime, and the terminal targets stay free of it. ArgumentParser parses the arguments and writes the help; `HarnessCLI` keeps the promises it would not: a usage error exits 2 where ArgumentParser exits 64, and the JSON or text form is read from the raw `--json` and `--pretty` before parsing, so a parse that fails is still reported in the form the caller asked for. A usage error says what is accepted where it happened: the commands for an unknown command, the app names for an unknown app, and the command's usage for a bad flag or argument. The executable prints a completion script for fish, zsh, and bash with `--generate-completion-script SHELL`, and `launch`, `shot`, and `tree` complete the registered names by asking the executable at completion time, so no list can fall out of date. `HarnessCLI.main(registry:)` stays the only entry point, and no command depends on how the executable was found, so a prebuilt front end or a SwiftPM plugin can forward to an app's executable unchanged. +The commands and flags are declared with [swift-argument-parser](https://github.com/apple/swift-argument-parser), and only `TesseraHarness` depends on it (`scripts/check-package-boundaries.py` allowlists the one product), so the library, the runtime, and the terminal targets stay free of it. ArgumentParser parses the arguments and writes the help; `HarnessCLI` keeps the promises it would not: a usage error exits 2 where ArgumentParser exits 64, and the JSON or text form is read from the raw `--json` and `--pretty` before parsing, so a parse that fails is still reported in the form the caller asked for. A usage error says what is accepted where it happened: the commands for an unknown command, the app names for an unknown app, and the command's usage for a bad flag or argument. The executable prints a completion script for fish, zsh, and bash with `--generate-completion-script SHELL`, and the commands that take an app name (`launch`, `shot`, `tree`, `check`, and `replay --app`) complete the registered names by asking the executable at completion time, so no list can fall out of date. `HarnessCLI.main(registry:)` stays the only entry point, and no command depends on how the executable was found, so a prebuilt front end or a SwiftPM plugin can forward to an app's executable unchanged. -Each `dev:*` task that takes an app name takes it as an optional argument. Run without one, it prints the command's help and the registered apps and exits 0, where mise would report a missing argument; `dev:run` without a path prints `run`'s help. `mise run dev:launch ` completes the app names, and `dev:shot` and `dev:tree` the sizes after them, from the `Examples/.build/debug/tessera` an earlier run built; completion never builds, so it offers nothing until there is one. +Each `dev:*` task that takes an app name takes it as an optional argument (`dev:replay`, its trace file). Run without one, it prints the command's help and the registered apps and exits 0, where mise would report a missing argument; `dev:run` without a path prints `run`'s help. `mise run dev:launch ` completes the app names, and `dev:shot`, `dev:tree`, and `dev:check` the sizes after them (`dev:replay` completes the names after `--app`), from the `Examples/.build/debug/tessera` an earlier run built; completion never builds, so it offers nothing until there is one. ### Stability diff --git a/docs/spec/module-and-file-layout.md b/docs/spec/module-and-file-layout.md index cbffb14..2311b2e 100644 --- a/docs/spec/module-and-file-layout.md +++ b/docs/spec/module-and-file-layout.md @@ -151,7 +151,7 @@ Sources/ Viewport.swift // Viewport (public) HarnessCLI.swift // HarnessCLI.main(registry:) (public): parsing, dispatch, exit codes HarnessCLI+Commands.swift // apps, launch, and the ArgumentParser declarations they share (internal) - HarnessCLI+Scenarios.swift // run, shot, and tree (internal) + HarnessCLI+Scenarios.swift // run, shot, tree, check, and replay (internal) Driver.swift // main-actor shell over the step (internal) HeadlessHost.swift // a session over a headless device into one virtual terminal (internal) HeadlessTerminalDevice.swift // the device under a headless session; answers its terminal queries (internal) From c61df24800b8abb9f3f482cdd58027b1e59aca94 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 15:19:55 -0400 Subject: [PATCH 41/55] feat(dev): explain dev:check and dev:replay without arguments and complete their app names --- .agents/skills/tessera-contributing/SKILL.md | 4 ++-- mise-tasks/dev/check | 11 +++++++++-- mise-tasks/dev/replay | 10 ++++++++-- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.agents/skills/tessera-contributing/SKILL.md b/.agents/skills/tessera-contributing/SKILL.md index dac85eb..55efb95 100644 --- a/.agents/skills/tessera-contributing/SKILL.md +++ b/.agents/skills/tessera-contributing/SKILL.md @@ -14,7 +14,7 @@ Everything in the [`tessera` skill](../tessera/SKILL.md) applies here: the `tess | --- | --- | --- | | `mise run dev:apps` | `tessera apps` | List the registered demos: name, minimum size, viewports. | | `mise run dev:launch NAME` | `tessera launch NAME` | Run one demo in this terminal until it quits. With `TESSERA_TRACE=FILE` it writes the session's trace. | -| `mise run dev:launch`, `dev:shot`, or `dev:tree` | `tessera help COMMAND`, then `tessera apps` | With no name, print the command's help and the demos, and exit 0. `dev:run` with no path prints its help. | +| `mise run dev:launch`, `dev:shot`, `dev:tree`, `dev:check`, or `dev:replay` | `tessera help COMMAND`, then `tessera apps` | With no name (or, for `dev:replay`, no file), print the command's help and the demos, and exit 0. `dev:run` with no path prints its help. | | `mise run dev:run PATH...` | `tessera run PATH...` | Run scenario files or directories of them, with `--record`, `--repeat N`, `--timing`, and `--trace DIR`. | | `mise run dev:shot NAME [SIZE]` | `tessera shot NAME [SIZE]` | Print one demo's screen as text. | | `mise run dev:tree NAME [SIZE]` | `tessera tree NAME [SIZE]` | Print one demo's node tree. | @@ -24,7 +24,7 @@ Everything in the [`tessera` skill](../tessera/SKILL.md) applies here: the `tess `mise run dev:*` builds and runs the `tessera` executable (`swift run --package-path Examples tessera …`), and `--json` and `--pretty` go through to it. It is the product of `Examples/Sources/TesseraDev`, whose registry is `Demos.registry()` from `Examples/Sources/DemoSupport`, so the apps it lists are Tessera's demos. -With mise's shell completions enabled, `mise run dev:launch ` offers the demo names, `dev:shot` and `dev:tree` offer them too and then the sizes, and `dev:run` offers `.scn` files and directories. Completion never builds: `scripts/dev-app-names.sh` asks the `Examples/.build/debug/tessera` that an earlier `dev:*` run built, and offers nothing before there is one. Run `mise run dev:apps` once after a fresh checkout, or after registering a demo. To complete `tessera` itself in your shell, see the `tessera` skill. +With mise's shell completions enabled, `mise run dev:launch ` offers the demo names, `dev:shot`, `dev:tree`, and `dev:check` offer them too and then the sizes, `dev:replay` offers them after `--app`, and `dev:run` offers `.scn` files and directories. Completion never builds: `scripts/dev-app-names.sh` asks the `Examples/.build/debug/tessera` that an earlier `dev:*` run built, and offers nothing before there is one. Run `mise run dev:apps` once after a fresh checkout, or after registering a demo. To complete `tessera` itself in your shell, see the `tessera` skill. ## The loop diff --git a/mise-tasks/dev/check b/mise-tasks/dev/check index f938e0e..1070915 100755 --- a/mise-tasks/dev/check +++ b/mise-tasks/dev/check @@ -1,9 +1,16 @@ #!/usr/bin/env bash #MISE description="Check one app against the invariant rules (tessera check NAME [SIZE])" -#USAGE arg "" help="App name, as listed by `mise run dev:apps`" +#USAGE arg "[name]" help="App name, as listed by `mise run dev:apps`; without one, shows the help and the apps" #USAGE arg "[size]" help="desktop, mobile, mobileScreen, or WxH; desktop when omitted" +#USAGE complete "name" run=#""$(git rev-parse --show-toplevel)/scripts/dev-app-names.sh""# +#USAGE complete "size" run=#"printf '%s\n' desktop mobile mobileScreen"# #USAGE flag "--json" help="Print one JSON object even on a terminal" #USAGE flag "--pretty" help="Print the text form even when output is not a terminal" set -euo pipefail cd "$MISE_PROJECT_ROOT" -swift run --only-use-versions-from-resolved-file --package-path Examples tessera check "$@" +source scripts/dev-tessera.sh +if [[ -z "${usage_name:-}" ]]; then + tessera_help_with_apps check + exit 0 +fi +tessera check "$@" diff --git a/mise-tasks/dev/replay b/mise-tasks/dev/replay index 303a16a..0d8efa6 100755 --- a/mise-tasks/dev/replay +++ b/mise-tasks/dev/replay @@ -1,9 +1,15 @@ #!/usr/bin/env bash #MISE description="Run a recorded trace again against an app (tessera replay FILE --app NAME)" -#USAGE arg "" help="A trace written by `launch` under TESSERA_TRACE or by `run --trace`" +#USAGE arg "[file]" help="A trace written by `launch` under TESSERA_TRACE or by `run --trace`; without one, shows the help and the apps" #USAGE flag "--app " help="The app to replay it against" +#USAGE complete "name" run=#""$(git rev-parse --show-toplevel)/scripts/dev-app-names.sh""# #USAGE flag "--json" help="Print one JSON object even on a terminal" #USAGE flag "--pretty" help="Print the text form even when output is not a terminal" set -euo pipefail cd "$MISE_PROJECT_ROOT" -swift run --only-use-versions-from-resolved-file --package-path Examples tessera replay "$@" +source scripts/dev-tessera.sh +if [[ -z "${usage_file:-}" ]]; then + tessera_help_with_apps replay + exit 0 +fi +tessera replay "$@" From 04ef8a5ee39dfcb7f1d44b23feafd8bdb4aedfd9 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 15:49:18 -0400 Subject: [PATCH 42/55] fix(dev): build tessera once per dev task and run the binary A dev task ran `swift run` for each tessera call, so one that calls it twice printed two SwiftPM build blocks. It now builds once with `swift build --quiet`, which prints nothing when it succeeds and still prints the compiler's errors when it fails, and runs the built binary for every call. --- .agents/skills/tessera-contributing/SKILL.md | 2 +- CHANGELOG.md | 2 +- docs/architecture/developer-tooling.md | 2 +- scripts/dev-tessera.sh | 23 +++++++++++++++++--- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.agents/skills/tessera-contributing/SKILL.md b/.agents/skills/tessera-contributing/SKILL.md index b6e785d..065ffe4 100644 --- a/.agents/skills/tessera-contributing/SKILL.md +++ b/.agents/skills/tessera-contributing/SKILL.md @@ -17,7 +17,7 @@ Everything in the [`tessera` skill](../tessera/SKILL.md) applies here: the `tess | `mise run dev:launch NAME` | `tessera launch NAME` | Run one demo in this terminal until it quits. | | `mise run dev:launch` | `tessera help launch`, then `tessera apps` | With no name, print the command's help and the demos, and exit 0. | -`mise run dev:*` builds and runs the `tessera` executable (`swift run --package-path Examples tessera …`). It is the product of `Examples/Sources/TesseraDev`, whose registry is `Demos.registry()` from `Examples/Sources/DemoSupport`, so the apps it lists are Tessera's demos. +`mise run dev:*` builds the `tessera` executable once, quietly (`swift build --quiet --package-path Examples --product tessera`), and runs the binary it built, so a build that succeeds prints nothing and one that fails still prints its errors; the binary keeps the task's terminal. It is the product of `Examples/Sources/TesseraDev`, whose registry is `Demos.registry()` from `Examples/Sources/DemoSupport`, so the apps it lists are Tessera's demos. With mise's shell completions enabled, `mise run dev:launch ` offers the demo names. Completion never builds: `scripts/dev-app-names.sh` asks the `Examples/.build/debug/tessera` that an earlier `dev:*` run built, and offers nothing before there is one. Run `mise run dev:apps` once after a fresh checkout, or after registering a demo. To complete `tessera` itself in your shell, see the `tessera` skill. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7493741..aa8d564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added the application registry, the viewports, and the main-actor driver to `TesseraHarness`, and the `tessera` command line over them. `ApplicationRegistry` selects an app by name through a main-actor factory that the harness opens once per run; registering a name twice makes every read of the registry fail rather than letting the later registration win. `Viewport` names `desktop` (80x24), `mobile` (40 columns at the app's natural height), `mobileScreen` (40x16), and `custom`; an app whose root grows with any height it is offered has no natural height, so running it at `mobile` fails and names `mobileScreen`. A `package` `Step.run(_:graph:event:isolation:)` in `TesseraRuntime` is the one loop body every host runs, and the `package` `Driver` is its main-actor shell, shared by a headless host (a terminal session over a headless device into a Ghostty virtual terminal) and a live host (a session over the real terminal), so a screen a test reads is the screen a terminal shows. `HarnessCLI.main(registry:)` runs `apps`, `launch NAME`, and `help [COMMAND]`. - Added the `tessera` executable (`Examples/Sources/TesseraDev`) with `mise run dev:apps` and `mise run dev:launch NAME`, and the `DemoSupport` library with its first demo, a counter that quits on `q`; its snapshot tests step it through the same driver as `tessera launch`. - Added the `tessera` agent skill for app authors (writing an `Application`, registering it, wiring the executable over `HarnessCLI.main(registry:)`, and `tessera apps` and `tessera launch`) and the `tessera-contributing` skill for contributors (the `mise run dev:*` tasks, the demos, and their snapshot tests), with a pointer from `AGENTS.md`. -- Added swift-argument-parser (`from: "1.8.2"`) as a dependency of `TesseraHarness` alone, and declared the `tessera` commands and flags with it. Exit codes (a usage error exits 2, not ArgumentParser's 64), the JSON object and the text form, and the command and flag names are unchanged; help and parse-error wording are ArgumentParser's, and a usage error still lists the accepted commands, app names, or the command's usage. The executable prints a completion script for fish, zsh, and bash (`--generate-completion-script SHELL`) that completes the registered app names for `launch`. `mise run dev:launch` with no name prints the command's help and the registered apps instead of mise's missing-argument error, and `mise run dev:launch ` completes the app names from the built `tessera` without building. +- Added swift-argument-parser (`from: "1.8.2"`) as a dependency of `TesseraHarness` alone, and declared the `tessera` commands and flags with it. Exit codes (a usage error exits 2, not ArgumentParser's 64), the JSON object and the text form, and the command and flag names are unchanged; help and parse-error wording are ArgumentParser's, and a usage error still lists the accepted commands, app names, or the command's usage. The executable prints a completion script for fish, zsh, and bash (`--generate-completion-script SHELL`) that completes the registered app names for `launch`. `mise run dev:launch` with no name prints the command's help and the registered apps instead of mise's missing-argument error, and `mise run dev:launch ` completes the app names from the built `tessera` without building. A `dev:*` task builds `tessera` once, quietly, and runs the binary it built. - Added two compile-fail fixtures that pin the isolation rules of `Application`, each with a positive control, to `mise run quality:architecture`: an actor host given an app with a main-actor isolated conformance, and a default-main-actor type adopted from another module without `@MainActor` on the conformance. - Added Windows Ghostty snapshot support with a static library, reproducible Zig build and cache tooling, Frost VM recipes, console mode setup and restoration, live terminal device I/O, focused cross-platform test recipes, and hosted CI coverage. - Added public issue, Discussion, and pull-request forms, private security and conduct reporting, solo-maintainer ownership metadata, and a fork-safe reviewed contributor vouch gate with tested trust parsing. diff --git a/docs/architecture/developer-tooling.md b/docs/architecture/developer-tooling.md index a59698a..c8ee599 100644 --- a/docs/architecture/developer-tooling.md +++ b/docs/architecture/developer-tooling.md @@ -179,7 +179,7 @@ Built today: `apps`, `launch NAME`, and `help [COMMAND]`, with `--json` and `--p The commands and flags are declared with [swift-argument-parser](https://github.com/apple/swift-argument-parser), and only `TesseraHarness` depends on it (`scripts/check-package-boundaries.py` allowlists the one product), so the library, the runtime, and the terminal targets stay free of it. ArgumentParser parses the arguments and writes the help; `HarnessCLI` keeps the promises it would not: a usage error exits 2 where ArgumentParser exits 64, and the JSON or text form is read from the raw `--json` and `--pretty` before parsing, so a parse that fails is still reported in the form the caller asked for. A usage error says what is accepted where it happened: the commands for an unknown command, the app names for an unknown app, and the command's usage for a bad flag or argument. The executable prints a completion script for fish, zsh, and bash with `--generate-completion-script SHELL`, and `launch` completes the registered names by asking the executable at completion time, so no list can fall out of date. `HarnessCLI.main(registry:)` stays the only entry point, and no command depends on how the executable was found, so a prebuilt front end or a SwiftPM plugin can forward to an app's executable unchanged. -Each `dev:*` task takes its app name as an optional argument. Run without one, it prints the command's help and the registered apps and exits 0, where mise would report a missing argument. `mise run dev:launch ` completes the app names, from the `Examples/.build/debug/tessera` an earlier run built; completion never builds, so it offers nothing until there is one. +Each `dev:*` task takes its app name as an optional argument. Run without one, it prints the command's help and the registered apps and exits 0, where mise would report a missing argument. A `dev:*` task builds `tessera` once, quietly, and runs the binary it built, so a build that succeeds prints nothing and one that fails still prints its errors. `mise run dev:launch ` completes the app names, from the `Examples/.build/debug/tessera` an earlier run built; completion never builds, so it offers nothing until there is one. ### Stability diff --git a/scripts/dev-tessera.sh b/scripts/dev-tessera.sh index c6c0228..27693df 100755 --- a/scripts/dev-tessera.sh +++ b/scripts/dev-tessera.sh @@ -1,9 +1,26 @@ #!/usr/bin/env bash -# Sourced by the `dev:*` mise tasks, which run from the repository root: it builds and runs -# the `tessera` executable of the Examples package. +# Sourced by the `dev:*` mise tasks, which run from the repository root: it builds the +# `tessera` executable of the Examples package once and runs the binary it built. +# Where the built executable is, once `tessera` has built it. +tessera_binary="" + +# Builds `tessera`, quietly: a build that succeeds prints nothing and one that fails still +# prints its errors. The flags are the ones every SwiftPM command here passes +# (docs/architecture/toolchain-lanes.md). +tessera_build() { + local flags=(--only-use-versions-from-resolved-file --package-path Examples) + swift build --quiet "${flags[@]}" --product tessera + tessera_binary="$(swift build --show-bin-path "${flags[@]}")/tessera" +} + +# Runs `tessera` with the given arguments, building it first the first time. A task that +# runs it twice builds once, and the binary keeps the task's terminal, as `launch` needs. tessera() { - swift run --only-use-versions-from-resolved-file --package-path Examples tessera "$@" + if [[ -z "$tessera_binary" ]]; then + tessera_build + fi + "$tessera_binary" "$@" } # Shows what a `dev:*` task that needs an app name says when it is run without one: the From a6a414a084ceffdbf7a3e36c0e02efc76b292091 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 15:52:26 -0400 Subject: [PATCH 43/55] fix(dev): run dev:help through the shared quiet build --- mise-tasks/dev/help | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mise-tasks/dev/help b/mise-tasks/dev/help index 5fd7f4b..fa8ffe4 100755 --- a/mise-tasks/dev/help +++ b/mise-tasks/dev/help @@ -3,4 +3,5 @@ #USAGE arg "[command]" help="apps, launch, run, shot, tree, check, replay, or help" set -euo pipefail cd "$MISE_PROJECT_ROOT" -swift run --only-use-versions-from-resolved-file --package-path Examples tessera help "$@" +source scripts/dev-tessera.sh +tessera help "$@" From dd6ac6918e23a7b35617741987ff17f3c677a12c Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 15:56:02 -0400 Subject: [PATCH 44/55] fix(harness): keep help with an extra word a usage error and test completion as the scripts ask help takes one command: `help launch extra` is a usage error that lists the commands again, and `help help` is pinned. The completion tests send the request the shipped scripts send (positional@0 and the option's flag) instead of the legacy argument name, for every command that takes an app name. --- Sources/TesseraHarness/HarnessCLI.swift | 29 +++-- .../TesseraHarnessTests/HarnessCLITests.swift | 103 +++++++++++++----- 2 files changed, 98 insertions(+), 34 deletions(-) diff --git a/Sources/TesseraHarness/HarnessCLI.swift b/Sources/TesseraHarness/HarnessCLI.swift index 7dfa7fa..c2d6f73 100644 --- a/Sources/TesseraHarness/HarnessCLI.swift +++ b/Sources/TesseraHarness/HarnessCLI.swift @@ -185,20 +185,33 @@ extension HarnessCLI { arguments.filter { !OutputForm.spellings.contains($0) } } - /// The usage error for a word that names no command, when the arguments hold one: the - /// first word, or the one after `help`. Arguments that start with an option hold none. + /// The usage error for words that name no command, when the arguments hold some: the first + /// word, or the words after `help`, which takes one. Arguments that start with an option + /// hold none. private static func unknownCommand(in arguments: [String], json: Bool) -> CommandOutput? { let words = withoutOutputFlags(arguments) - let afterHelp = words.first == "help" - guard let word = words.dropFirst(afterHelp ? 1 : 0).first, !word.hasPrefix("-"), - !commandNames.contains(word) - else { + let accepted = commandNames.joined(separator: ", ") + guard words.first == "help" else { + guard let word = words.first, !word.hasPrefix("-"), !commandNames.contains(word) + else { + return nil + } + return usage( + "unknown command `\(word)`; accepted: \(accepted)", command: nil, json: json) + } + // ArgumentParser's `help` takes any number of words and describes the first it knows. + let topics = words.dropFirst().filter { !$0.hasPrefix("-") } + if topics.count > 1 { + return usage( + "`help` takes one command, got `\(topics.joined(separator: " "))`; accepted: \(accepted)", + command: "help", json: json) + } + guard let topic = topics.first, !commandNames.contains(topic) else { return nil } return usage( - "unknown command `\(word)`; accepted: \(commandNames.joined(separator: ", "))", - command: afterHelp ? "help" : nil, json: json) + "unknown command `\(topic)`; accepted: \(accepted)", command: "help", json: json) } /// What ArgumentParser's error means here. diff --git a/Tests/TesseraHarnessTests/HarnessCLITests.swift b/Tests/TesseraHarnessTests/HarnessCLITests.swift index 53d1aad..5b50961 100644 --- a/Tests/TesseraHarnessTests/HarnessCLITests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLITests.swift @@ -9,6 +9,14 @@ import Testing @testable import TesseraHarness +/// What a shell's completion script sends to ask for the candidates of a command's app name: +/// the command, `--`, the key of the argument (`positional@0` for a positional, the flag for +/// an option), the index of the word being completed, the cursor in it, and the words so +/// far, the executable first. +private let completionRequests: [[String]] = [ + ["launch", "--", "positional@0", "2", "0", "tessera", "launch", ""] +] + @MainActor struct HarnessCLITests { /// Runs a command. `terminal` says standard output is a terminal and `input` says standard @@ -19,6 +27,18 @@ struct HarnessCLITests { terminal: Bool = false, input: Bool = false, executable: String = "tessera" + ) async -> CommandOutput { + await run( + arguments: arguments, registry: registry, terminal: terminal, input: input, + executable: executable) + } + + private func run( + arguments: [String], + registry: ApplicationRegistry? = nil, + terminal: Bool = false, + input: Bool = false, + executable: String = "tessera" ) async -> CommandOutput { await HarnessCLI.run( arguments: arguments, @@ -424,11 +444,15 @@ struct HarnessCLITests { } @Test - func `help for an unknown command is a usage error that lists the commands`() async { + func `help for an unknown or extra word is a usage error that lists the commands`() + async + { let json = await run("help", "ghost") let text = await run("help", "ghost", "--pretty") + let extra = await run("help", "launch", "extra", "--pretty") + let both = await run("help", "ghost", "launch", "--pretty") - assertInlineSnapshot(of: [json, text], as: .customDump) { + assertInlineSnapshot(of: [json, text, extra, both], as: .customDump) { #""" [ [0]: CommandOutput( @@ -445,6 +469,22 @@ struct HarnessCLITests { stderr: """ error: unknown command `ghost`; accepted: apps, launch, help + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `help` takes one command, got `launch extra`; accepted: apps, launch, help + + """ + ), + [3]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `help` takes one command, got `ghost launch`; accepted: apps, launch, help + """ ) ] @@ -452,6 +492,30 @@ struct HarnessCLITests { } } + @Test + func `help help describes help as ArgumentParser words it`() async { + let output = await run("help", "help") + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 0, + stdout: """ + OVERVIEW: Show subcommand help information. + + USAGE: tessera help [ ...] + + ARGUMENTS: + + + + """, + stderr: "" + ) + """# + } + } + @Test func `the parser's usage errors exit 2, not its own 64, and say what is accepted`() async @@ -557,33 +621,20 @@ struct HarnessCLITests { #expect(before.exitCode == 0) } - @Test - func `the app-name completion lists the registered names`() async { - let output = await run("---completion", "launch", "--", "name", "0", "0", "launch") + @Test(arguments: completionRequests) + func `every command that takes an app name completes the registered names`( + request: [String] + ) async throws { + let output = await run(arguments: ["---completion"] + request) - assertInlineSnapshot(of: output, as: .customDump) { - #""" - CommandOutput( - exitCode: 0, - stdout: """ - counter - greedy - main-actor-counter - observed-counter - stack - workspace - - """, - stderr: "" - ) - """# - } + let names = try Fixtures.registry().names() + expectNoDifference( + output, CommandOutput(exitCode: 0, stdout: names.map { $0 + "\n" }.joined())) } - @Test - func `a registry with a duplicate name completes nothing`() async { - let output = await run( - "---completion", "launch", "--", "name", "0", "0", "launch", registry: duplicated()) + @Test(arguments: completionRequests) + func `a registry with a duplicate name completes nothing`(request: [String]) async { + let output = await run(arguments: ["---completion"] + request, registry: duplicated()) expectNoDifference(output, CommandOutput(exitCode: 0)) } From ee42d756f6525471bb0ec57944ef966c8ac5e03c Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 15:56:03 -0400 Subject: [PATCH 45/55] fix(dev): exit 2 from a dev task given no argument unless stdout is a terminal A task run without its argument, or with an empty one, printed help and exited 0 even in a script, where an unset variable then passed for success. It exits 0 at a terminal and 2 in a pipe or a script, as tessera does. --- .agents/skills/tessera-contributing/SKILL.md | 2 +- CHANGELOG.md | 2 +- docs/architecture/developer-tooling.md | 2 +- ...hase-4-view-layer-and-developer-tooling.md | 2 +- mise-tasks/dev/launch | 3 +-- scripts/dev-tessera.sh | 24 +++++++++++++++---- 6 files changed, 25 insertions(+), 10 deletions(-) diff --git a/.agents/skills/tessera-contributing/SKILL.md b/.agents/skills/tessera-contributing/SKILL.md index 065ffe4..39f5cc6 100644 --- a/.agents/skills/tessera-contributing/SKILL.md +++ b/.agents/skills/tessera-contributing/SKILL.md @@ -15,7 +15,7 @@ Everything in the [`tessera` skill](../tessera/SKILL.md) applies here: the `tess | `mise run dev:apps` | `tessera apps` | List the registered demos: name, minimum size, viewports. | | `mise run dev:apps --json` or `--pretty` | `tessera apps --json` or `--pretty` | Force the JSON object or the text table. | | `mise run dev:launch NAME` | `tessera launch NAME` | Run one demo in this terminal until it quits. | -| `mise run dev:launch` | `tessera help launch`, then `tessera apps` | With no name, print the command's help and the demos, and exit 0. | +| `mise run dev:launch` | `tessera help launch`, then `tessera apps` | With no name, print the command's help and the demos. At a terminal that is the answer and the task exits 0; in a pipe or a script nothing ran and it exits 2, as `tessera` does. | `mise run dev:*` builds the `tessera` executable once, quietly (`swift build --quiet --package-path Examples --product tessera`), and runs the binary it built, so a build that succeeds prints nothing and one that fails still prints its errors; the binary keeps the task's terminal. It is the product of `Examples/Sources/TesseraDev`, whose registry is `Demos.registry()` from `Examples/Sources/DemoSupport`, so the apps it lists are Tessera's demos. diff --git a/CHANGELOG.md b/CHANGELOG.md index aa8d564..116d0e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Added the application registry, the viewports, and the main-actor driver to `TesseraHarness`, and the `tessera` command line over them. `ApplicationRegistry` selects an app by name through a main-actor factory that the harness opens once per run; registering a name twice makes every read of the registry fail rather than letting the later registration win. `Viewport` names `desktop` (80x24), `mobile` (40 columns at the app's natural height), `mobileScreen` (40x16), and `custom`; an app whose root grows with any height it is offered has no natural height, so running it at `mobile` fails and names `mobileScreen`. A `package` `Step.run(_:graph:event:isolation:)` in `TesseraRuntime` is the one loop body every host runs, and the `package` `Driver` is its main-actor shell, shared by a headless host (a terminal session over a headless device into a Ghostty virtual terminal) and a live host (a session over the real terminal), so a screen a test reads is the screen a terminal shows. `HarnessCLI.main(registry:)` runs `apps`, `launch NAME`, and `help [COMMAND]`. - Added the `tessera` executable (`Examples/Sources/TesseraDev`) with `mise run dev:apps` and `mise run dev:launch NAME`, and the `DemoSupport` library with its first demo, a counter that quits on `q`; its snapshot tests step it through the same driver as `tessera launch`. - Added the `tessera` agent skill for app authors (writing an `Application`, registering it, wiring the executable over `HarnessCLI.main(registry:)`, and `tessera apps` and `tessera launch`) and the `tessera-contributing` skill for contributors (the `mise run dev:*` tasks, the demos, and their snapshot tests), with a pointer from `AGENTS.md`. -- Added swift-argument-parser (`from: "1.8.2"`) as a dependency of `TesseraHarness` alone, and declared the `tessera` commands and flags with it. Exit codes (a usage error exits 2, not ArgumentParser's 64), the JSON object and the text form, and the command and flag names are unchanged; help and parse-error wording are ArgumentParser's, and a usage error still lists the accepted commands, app names, or the command's usage. The executable prints a completion script for fish, zsh, and bash (`--generate-completion-script SHELL`) that completes the registered app names for `launch`. `mise run dev:launch` with no name prints the command's help and the registered apps instead of mise's missing-argument error, and `mise run dev:launch ` completes the app names from the built `tessera` without building. A `dev:*` task builds `tessera` once, quietly, and runs the binary it built. +- Added swift-argument-parser (`from: "1.8.2"`) as a dependency of `TesseraHarness` alone, and declared the `tessera` commands and flags with it. Exit codes (a usage error exits 2, not ArgumentParser's 64), the JSON object and the text form, and the command and flag names are unchanged; help and parse-error wording are ArgumentParser's, and a usage error still lists the accepted commands, app names, or the command's usage. The executable prints a completion script for fish, zsh, and bash (`--generate-completion-script SHELL`) that completes the registered app names for `launch`. `mise run dev:launch` with no name prints the command's help and the registered apps instead of mise's missing-argument error (exit 0 at a terminal, 2 in a pipe or a script), and `mise run dev:launch ` completes the app names from the built `tessera` without building. A `dev:*` task builds `tessera` once, quietly, and runs the binary it built. - Added two compile-fail fixtures that pin the isolation rules of `Application`, each with a positive control, to `mise run quality:architecture`: an actor host given an app with a main-actor isolated conformance, and a default-main-actor type adopted from another module without `@MainActor` on the conformance. - Added Windows Ghostty snapshot support with a static library, reproducible Zig build and cache tooling, Frost VM recipes, console mode setup and restoration, live terminal device I/O, focused cross-platform test recipes, and hosted CI coverage. - Added public issue, Discussion, and pull-request forms, private security and conduct reporting, solo-maintainer ownership metadata, and a fork-safe reviewed contributor vouch gate with tested trust parsing. diff --git a/docs/architecture/developer-tooling.md b/docs/architecture/developer-tooling.md index c8ee599..d2bd7f6 100644 --- a/docs/architecture/developer-tooling.md +++ b/docs/architecture/developer-tooling.md @@ -179,7 +179,7 @@ Built today: `apps`, `launch NAME`, and `help [COMMAND]`, with `--json` and `--p The commands and flags are declared with [swift-argument-parser](https://github.com/apple/swift-argument-parser), and only `TesseraHarness` depends on it (`scripts/check-package-boundaries.py` allowlists the one product), so the library, the runtime, and the terminal targets stay free of it. ArgumentParser parses the arguments and writes the help; `HarnessCLI` keeps the promises it would not: a usage error exits 2 where ArgumentParser exits 64, and the JSON or text form is read from the raw `--json` and `--pretty` before parsing, so a parse that fails is still reported in the form the caller asked for. A usage error says what is accepted where it happened: the commands for an unknown command, the app names for an unknown app, and the command's usage for a bad flag or argument. The executable prints a completion script for fish, zsh, and bash with `--generate-completion-script SHELL`, and `launch` completes the registered names by asking the executable at completion time, so no list can fall out of date. `HarnessCLI.main(registry:)` stays the only entry point, and no command depends on how the executable was found, so a prebuilt front end or a SwiftPM plugin can forward to an app's executable unchanged. -Each `dev:*` task takes its app name as an optional argument. Run without one, it prints the command's help and the registered apps and exits 0, where mise would report a missing argument. A `dev:*` task builds `tessera` once, quietly, and runs the binary it built, so a build that succeeds prints nothing and one that fails still prints its errors. `mise run dev:launch ` completes the app names, from the `Examples/.build/debug/tessera` an earlier run built; completion never builds, so it offers nothing until there is one. +Each `dev:*` task takes its app name as an optional argument. Run without one, it prints the command's help and the registered apps, where mise would report a missing argument. At a terminal that is the answer and the task exits 0; in a pipe or a script nothing ran, so the task exits 2, as `tessera` does, and an unset variable cannot pass for success. A `dev:*` task builds `tessera` once, quietly, and runs the binary it built, so a build that succeeds prints nothing and one that fails still prints its errors. `mise run dev:launch ` completes the app names, from the `Examples/.build/debug/tessera` an earlier run built; completion never builds, so it offers nothing until there is one. ### Stability 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 f9fb450..fd0cbfa 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -275,7 +275,7 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-24 The registry and demo shapes. The two spike fixtures (`StackApp`, `WorkspaceApp`) moved out of the library into `Tests/TesseraHarnessTests/Fixtures.swift` as `Application` types, with a greedy fixture, counters of each adoption shape, and a still app, so `TesseraHarness` no longer imports `TesseraLayout` or `TesseraWidgets` and its allowlist row dropped them. `Demo` requires `name`, `minimum`, `viewports`, `fixtures`, and `init()` with no defaults; a `DemoFixture` is a name and a main-actor factory that inherits the demo's minimum and viewports; `Demos.registry()` is the one list. The counter (32x5 minimum, `desktop` and `mobile`) quits on `q` through `isQuitKey`, which compares code, modifiers, and kind, and has one fixture (`counter-high`). `ActorHost` is the per-test actor shell; its initializer is `async`, because a synchronous actor initializer is nonisolated and cannot pass `self` as the step's isolation. The driver, the three hosts, the natural-height probe, the headless device, and the stream shim are internal rather than `package` (critic review of PR #65, F2): nothing outside `TesseraHarness` needs them, since the Examples tests go through `withHeadlessHost` and the `@testable` import reaches internal declarations anyway; the step, the registry's reads, `ScenarioHost`, and `withHeadlessHost` are `package`. If a later slice needs a host from another target, widening it is one keyword. (this plan, Slice 4 run) - 2026-10-24 The Examples tests run in CI from this slice (critic review of PR #65, F4). `ci.yml` gained a `Test Examples` step after `Test` (`swift test --package-path Examples`, on every leg), and `ci:build-test` the same line, so the counter's snapshot tests and the quit-key tests cannot regress unseen; Slice 5 adds the scenario corpus run to it. (this plan, Slice 4 run) - 2026-10-24 The `tessera-devtools` skill is split in two (owner, Q13 option (a)). `.agents/skills/tessera/` is the published skill for app authors: the `tessera` CLI and `TesseraHarness` as an app author sees them, with one `| tessera | Does |` table, no repository paths, `mise`, demos, `DemoSupport`, or `@testable` API, and the rule that a Tessera bug is reported and not worked around. `.agents/skills/tessera-contributing/` is the in-repo contributor skill: it opens by saying everything in `tessera` applies, then adds a `| mise | tessera | Does |` table (`mise run dev:*` builds and runs the `tessera` executable), the demos and `DemoSupport`, adding a demo with its `withHeadlessHost` snapshot test, and the rule that the library is fixed with a regression test and a demo is never bent. Both skills describe only what exists, written as the finished current skill, with no future work in them. The published skill sits beside the repository's other skills so agents working here find it, and it links to nothing outside its own directory, so publishing it later is a copy of that directory; there is no symlink, because Windows checkouts handle them badly. `tessera-contributing` is named for who it serves (the repository has a `CONTRIBUTING.md`), where `tessera-devtools` named the tools and would now sit confusingly beside `tessera`. How the `tessera` skill is distributed is settled with Decision 9 (iii). This supersedes the skill sentences of the 2026-10-22 entry on the tools' design. (owner) -- 2026-10-24 The `tessera` CLI adopts swift-argument-parser, and the `dev:*` tasks complete and explain themselves (owner, review of PR #65). `HarnessCLI` declares its commands with `apple/swift-argument-parser` (`from: "1.8.2"`, the newest release, which fixes fish completion scripts) now, before the CLI surface freezes at 1.0. Only `TesseraHarness` depends on it, and `scripts/check-package-boundaries.py` allowlists that one product; `Tessera`, core, layout, widgets, and the terminal targets do not. The stable surface is unchanged: exit codes 0 to 3 (`HarnessCLI` maps ArgumentParser's usage exit 64 to 2), one JSON object when stdout is not a terminal or under `--json` and the text form under `--pretty`, `{"command","exitCode","message"}` for a failure, the command and flag names, and `help [COMMAND]`; help text and parse-error wording become ArgumentParser's. The executable completes app names itself through ArgumentParser's custom completion, with `--generate-completion-script SHELL` for fish, zsh, and bash, so `tessera launch ` works under each. A `dev:*` task run with no app name prints the command's help and the registered apps and exits 0 instead of mise's "Missing required arg" error, and `mise run dev:launch ` completes the names, through the usage spec's `arg "[name]"` and `complete "name" run=...`, from the already-built `Examples/.build/debug/tessera`, printing nothing rather than building when there is none. (owner) +- 2026-10-24 The `tessera` CLI adopts swift-argument-parser, and the `dev:*` tasks complete and explain themselves (owner, review of PR #65). `HarnessCLI` declares its commands with `apple/swift-argument-parser` (`from: "1.8.2"`, the newest release, which fixes fish completion scripts) now, before the CLI surface freezes at 1.0. Only `TesseraHarness` depends on it, and `scripts/check-package-boundaries.py` allowlists that one product; `Tessera`, core, layout, widgets, and the terminal targets do not. The stable surface is unchanged: exit codes 0 to 3 (`HarnessCLI` maps ArgumentParser's usage exit 64 to 2), one JSON object when stdout is not a terminal or under `--json` and the text form under `--pretty`, `{"command","exitCode","message"}` for a failure, the command and flag names, and `help [COMMAND]`; help text and parse-error wording become ArgumentParser's. The executable completes app names itself through ArgumentParser's custom completion, with `--generate-completion-script SHELL` for fish, zsh, and bash, so `tessera launch ` works under each. A `dev:*` task run with no app name prints the command's help and the registered apps instead of mise's "Missing required arg" error, and exits 0 at a terminal and 2 in a pipe or a script, as `tessera` does, and `mise run dev:launch ` completes the names, through the usage spec's `arg "[name]"` and `complete "name" run=...`, from the already-built `Examples/.build/debug/tessera`, printing nothing rather than building when there is none. (owner) - Open, Decision 9: how app authors consume the harness. (i) Decided 2026-10-23; see the entries above. (ii) Distribution of libghostty-vt: decided 2026-10-22 (owner) after [libghostty-vt distribution](../research/libghostty-vt-distribution.md): an SE-0482 static-library artifact bundle built by Tessera's CI once per Ghostty pin and published as a release asset behind `binaryTarget(url:checksum:)`, with the pinned headers committed in `CGhosttyVT`. Taken and implemented 2026-10-22 by [Ship libghostty-vt as a prebuilt binary](libghostty-vt-binary-distribution.md): `Package.swift` links the `ghostty-vt-f54340ad7781` release, no test job builds Zig, and [libghostty-vt distribution](../architecture/libghostty-vt-distribution.md) records the rule. (iii) How an app gets `tessera`: a per-app executable calling `HarnessCLI.main(registry:)`, a prebuilt front end (GitHub releases, Homebrew, mise) that builds and forwards to that executable, or a SwiftPM command plugin. Deferred by the owner (2026-10-22); a per-app executable is needed under every option, so Slices 4-18 build that and nothing forecloses the others; settle before Slice 19. The owner leans toward the SwiftPM command plugin (2026-10-24); still to be settled before Slice 19. ## Discoveries diff --git a/mise-tasks/dev/launch b/mise-tasks/dev/launch index ef31ba3..a9d3ec0 100755 --- a/mise-tasks/dev/launch +++ b/mise-tasks/dev/launch @@ -6,7 +6,6 @@ set -euo pipefail cd "$MISE_PROJECT_ROOT" source scripts/dev-tessera.sh if [[ -z "${usage_name:-}" ]]; then - tessera_help_with_apps launch - exit 0 + tessera_exit_with_help_and_apps launch fi tessera launch "$usage_name" diff --git a/scripts/dev-tessera.sh b/scripts/dev-tessera.sh index 27693df..9d5ec0c 100755 --- a/scripts/dev-tessera.sh +++ b/scripts/dev-tessera.sh @@ -23,11 +23,27 @@ tessera() { "$tessera_binary" "$@" } -# Shows what a `dev:*` task that needs an app name says when it is run without one: the -# command's help, then the apps it can be given. It is help, not a failure, so mise does -# not report the task as failed. -tessera_help_with_apps() { +# Ends a `dev:*` task that was run without what it needs, after the command's help has been +# printed. A person at a terminal asked a question and got the answer, so the task succeeds +# and mise reports no failure. In a pipe or a script nothing ran, which is `tessera`'s own +# usage error, so the task exits 2 and an unset variable cannot pass for success. +tessera_end_with_help() { + if [[ -t 1 ]]; then + exit 0 + fi + exit 2 +} + +# Prints the help of `tessera COMMAND`, then ends the task. +tessera_exit_with_help() { + tessera help "$1" + tessera_end_with_help +} + +# Prints the help of `tessera COMMAND` and the apps it can be given, then ends the task. +tessera_exit_with_help_and_apps() { tessera help "$1" echo tessera apps --pretty + tessera_end_with_help } From 1b6efc005eaac90b7ae2b1116fbe8d26a4f11be5 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 16:00:18 -0400 Subject: [PATCH 46/55] fix(harness): refuse a repeated --repeat again and test shot and tree completion ArgumentParser takes the last value of a repeated option, so --repeat is declared as an array and validate() rejects a second one with the message the command line always gave. The completion tests also send the request the shipped scripts send for shot and tree. --- .../TesseraHarness/HarnessCLI+Scenarios.swift | 18 +++++++++- .../HarnessCLIScenarioTests.swift | 35 +++++++++++++++---- .../TesseraHarnessTests/HarnessCLITests.swift | 8 +++-- 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/Sources/TesseraHarness/HarnessCLI+Scenarios.swift b/Sources/TesseraHarness/HarnessCLI+Scenarios.swift index bbe4393..12d2457 100644 --- a/Sources/TesseraHarness/HarnessCLI+Scenarios.swift +++ b/Sources/TesseraHarness/HarnessCLI+Scenarios.swift @@ -47,6 +47,16 @@ extension HarnessCLI { } } +/// Refuses a flag given more than once, as the command line always has. +/// +/// ArgumentParser takes the last value of a repeated option, so an option that must be given +/// at most once is declared as an array and checked here. +private func refuseRepeats(of flag: String, given count: Int) throws { + guard count <= 1 else { + throw ValidationError("`\(flag)` is given twice") + } +} + /// `NAME [SIZE]`, the arguments `shot` and `tree` take. struct PictureArguments: ParsableArguments { @Argument( @@ -129,7 +139,7 @@ struct RunCommand: HarnessCommand { help: ArgumentHelp( "Run each scenario N times, and fail a run whose report differs from the first.", valueName: "n")) - var repetitions = 1 + var repeatValues: [Int] = [] @Flag( help: """ @@ -143,7 +153,13 @@ struct RunCommand: HarnessCommand { completion: .file(extensions: ["scn"])) var paths: [String] + /// The number of times to run each scenario: the one `--repeat` value, or 1. + private var repetitions: Int { + repeatValues.first ?? 1 + } + func validate() throws { + try refuseRepeats(of: "--repeat", given: repeatValues.count) guard repetitions >= 1 else { throw ValidationError( "`--repeat` needs a whole number of at least 1, got `\(repetitions)`") diff --git a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift index 4770c9b..1ee2dae 100644 --- a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift @@ -238,7 +238,7 @@ struct HarnessCLIScenarioTests { CommandOutput( exitCode: 2, stdout: """ - {"command":"run","exitCode":2,"message":"Missing expected argument ' ...'; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ..."} + {"command":"run","exitCode":2,"message":"Missing expected argument ' ...'; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ..."} """, stderr: "" @@ -253,15 +253,20 @@ struct HarnessCLIScenarioTests { 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") + let sameTwice = await run( + "run", "x.scn", "--repeat", "2", "--repeat", "2", "--pretty") - assertInlineSnapshot(of: [unknown, noValue, zero, word], as: .customDump) { + assertInlineSnapshot( + of: [unknown, noValue, zero, word, twice, sameTwice], as: .customDump + ) { #""" [ [0]: CommandOutput( exitCode: 2, stdout: "", stderr: """ - error: Unknown option '--frobnicate'; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: Unknown option '--frobnicate'; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... """ ), @@ -269,7 +274,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: Missing value for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: Missing value for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... """ ), @@ -277,7 +282,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: `--repeat` needs a whole number of at least 1, got `0`; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: `--repeat` needs a whole number of at least 1, got `0`; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... """ ), @@ -285,7 +290,23 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: The value 'many' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: The value 'many' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... + + """ + ), + [4]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... + + """ + ), + [5]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... """ ) @@ -306,7 +327,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: The value 'x.scn' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + error: The value 'x.scn' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... """ ) diff --git a/Tests/TesseraHarnessTests/HarnessCLITests.swift b/Tests/TesseraHarnessTests/HarnessCLITests.swift index a2c329f..f7d81b6 100644 --- a/Tests/TesseraHarnessTests/HarnessCLITests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLITests.swift @@ -14,7 +14,9 @@ import Testing /// an option), the index of the word being completed, the cursor in it, and the words so /// far, the executable first. private let completionRequests: [[String]] = [ - ["launch", "--", "positional@0", "2", "0", "tessera", "launch", ""] + ["launch", "--", "positional@0", "2", "0", "tessera", "launch", ""], + ["shot", "--", "positional@0", "2", "0", "tessera", "shot", ""], + ["tree", "--", "positional@0", "2", "0", "tessera", "tree", ""], ] @MainActor @@ -496,7 +498,7 @@ struct HarnessCLITests { exitCode: 2, stdout: "", stderr: """ - error: `help` takes one command, got `launch extra`; accepted: apps, launch, help + error: `help` takes one command, got `launch extra`; accepted: apps, launch, run, shot, tree, help """ ), @@ -504,7 +506,7 @@ struct HarnessCLITests { exitCode: 2, stdout: "", stderr: """ - error: `help` takes one command, got `ghost launch`; accepted: apps, launch, help + error: `help` takes one command, got `ghost launch`; accepted: apps, launch, run, shot, tree, help """ ) From 747322a40065e977505691e8130b9842ec0ef5f5 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 16:00:19 -0400 Subject: [PATCH 47/55] fix(dev): exit 2 from dev:run, dev:shot, and dev:tree without arguments off a terminal, and complete dev:run with scenarios only --- mise-tasks/dev/run | 4 ++-- mise-tasks/dev/shot | 3 +-- mise-tasks/dev/tree | 3 +-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/mise-tasks/dev/run b/mise-tasks/dev/run index ab27376..cbe2144 100755 --- a/mise-tasks/dev/run +++ b/mise-tasks/dev/run @@ -1,6 +1,7 @@ #!/usr/bin/env bash #MISE description="Run scenario files or directories of them (tessera run PATH...)" #USAGE arg "[path]" help="A .scn file or a directory of them, such as Examples/Scenarios; without one, shows the help" var=#true +#USAGE complete "path" type="path:scn" #USAGE flag "--record" help="Write the goldens that shot statements compare against, and list what changed" #USAGE flag "--repeat " help="Run each scenario N times and fail a run that differs from the first" #USAGE flag "--timing" help="Add the wall-clock seconds of each run to the report" @@ -10,8 +11,7 @@ set -euo pipefail cd "$MISE_PROJECT_ROOT" source scripts/dev-tessera.sh if [[ -z "${usage_path:-}" ]]; then - tessera help run - exit 0 + tessera_exit_with_help run fi # The words go to tessera as typed: it owns the flags, and the lines above document them. tessera run "$@" diff --git a/mise-tasks/dev/shot b/mise-tasks/dev/shot index a25414e..1d8c2f4 100755 --- a/mise-tasks/dev/shot +++ b/mise-tasks/dev/shot @@ -10,7 +10,6 @@ set -euo pipefail cd "$MISE_PROJECT_ROOT" source scripts/dev-tessera.sh if [[ -z "${usage_name:-}" ]]; then - tessera_help_with_apps shot - exit 0 + tessera_exit_with_help_and_apps shot fi tessera shot "$@" diff --git a/mise-tasks/dev/tree b/mise-tasks/dev/tree index ffbe994..6029544 100755 --- a/mise-tasks/dev/tree +++ b/mise-tasks/dev/tree @@ -10,7 +10,6 @@ set -euo pipefail cd "$MISE_PROJECT_ROOT" source scripts/dev-tessera.sh if [[ -z "${usage_name:-}" ]]; then - tessera_help_with_apps tree - exit 0 + tessera_exit_with_help_and_apps tree fi tessera tree "$@" From 1c93465fbdb3c206e67600c9d375665f60207371 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 16:05:32 -0400 Subject: [PATCH 48/55] fix(harness): refuse a repeated --trace or --app again and test check and replay completion Like --repeat, --trace and --app are declared as arrays so validate() can reject a second one with the message the command line always gave, and the completion tests send the request the shipped scripts send for check and for replay's --app. --- .../TesseraHarness/HarnessCLI+Scenarios.swift | 17 +++++- .../HarnessCLIScenarioTests.swift | 56 +++++++++++++------ .../TesseraHarnessTests/HarnessCLITests.swift | 6 +- 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/Sources/TesseraHarness/HarnessCLI+Scenarios.swift b/Sources/TesseraHarness/HarnessCLI+Scenarios.swift index a248c5e..b502517 100644 --- a/Sources/TesseraHarness/HarnessCLI+Scenarios.swift +++ b/Sources/TesseraHarness/HarnessCLI+Scenarios.swift @@ -159,6 +159,7 @@ struct RunCommand: HarnessCommand { var timing = false @Option( + name: .customLong("trace"), help: ArgumentHelp( """ Write each scenario's trace under DIR, as DIR/PATH.trace.jsonl, one JSON record per \ @@ -167,7 +168,7 @@ struct RunCommand: HarnessCommand { is not promised to stay as it is. """, valueName: "dir")) - var trace: String? + var traceValues: [String] = [] @Argument( help: ArgumentHelp("A .scn file, or a directory of them.", valueName: "path"), @@ -179,8 +180,14 @@ struct RunCommand: HarnessCommand { repeatValues.first ?? 1 } + /// The directory to write traces under: the one `--trace` value, if any. + private var trace: String? { + traceValues.first + } + func validate() throws { try refuseRepeats(of: "--repeat", given: repeatValues.count) + try refuseRepeats(of: "--trace", given: traceValues.count) guard repetitions >= 1 else { throw ValidationError( "`--repeat` needs a whole number of at least 1, got `\(repetitions)`") @@ -399,9 +406,15 @@ struct ReplayCommand: HarnessCommand { help: ArgumentHelp( "The application to replay it against, as `apps` lists it.", valueName: "name"), completion: HarnessCLI.appNameCompletion) - var app: String + var appValues: [String] + + /// The application to replay against: the one `--app` value. + private var app: String { + appValues.first ?? "" + } func validate() throws { + try refuseRepeats(of: "--app", given: appValues.count) guard !file.isEmpty else { throw ValidationError(emptyArgumentMessage) } diff --git a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift index fb89fa8..de39480 100644 --- a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift @@ -240,7 +240,7 @@ struct HarnessCLIScenarioTests { CommandOutput( exitCode: 2, stdout: """ - {"command":"run","exitCode":2,"message":"Missing expected argument ' ...'; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ..."} + {"command":"run","exitCode":2,"message":"Missing expected argument ' ...'; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] [--trace ...] ..."} """, stderr: "" @@ -258,9 +258,11 @@ struct HarnessCLIScenarioTests { let twice = await run("run", "x.scn", "--repeat", "2", "--repeat", "3", "--pretty") let sameTwice = await run( "run", "x.scn", "--repeat", "2", "--repeat", "2", "--pretty") + let traceTwice = await run( + "run", "x.scn", "--trace", "a", "--trace", "b", "--pretty") assertInlineSnapshot( - of: [unknown, noValue, zero, word, twice, sameTwice], as: .customDump + of: [unknown, noValue, zero, word, twice, sameTwice, traceTwice], as: .customDump ) { #""" [ @@ -268,7 +270,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: Unknown option '--frobnicate'; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... + error: Unknown option '--frobnicate'; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] [--trace ...] ... """ ), @@ -276,7 +278,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: Missing value for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... + error: Missing value for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] [--trace ...] ... """ ), @@ -284,7 +286,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: `--repeat` needs a whole number of at least 1, got `0`; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... + error: `--repeat` needs a whole number of at least 1, got `0`; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] [--trace ...] ... """ ), @@ -292,7 +294,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: The value 'many' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... + error: The value 'many' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] [--trace ...] ... """ ), @@ -300,7 +302,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... + error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] [--trace ...] ... """ ), @@ -308,7 +310,15 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... + error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] [--trace ...] ... + + """ + ), + [6]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--trace` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] [--trace ...] ... """ ) @@ -329,7 +339,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: The value 'x.scn' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... + error: The value 'x.scn' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] [--trace ...] ... """ ) @@ -770,7 +780,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: `--trace` needs a value that is not empty; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... + error: `--trace` needs a value that is not empty; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] [--trace ...] ... """ ), @@ -778,7 +788,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: an empty argument is not a path or an application name; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... + error: an empty argument is not a path or an application name; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] [--trace ...] ... """ ), @@ -786,7 +796,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: an empty argument is not a path or an application name; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ... + error: an empty argument is not a path or an application name; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] [--trace ...] ... """ ), @@ -818,7 +828,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: an empty argument is not a path or an application name; usage: tessera replay [--json] [--pretty] --app + error: an empty argument is not a path or an application name; usage: tessera replay [--json] [--pretty] --app ... """ ), @@ -826,14 +836,14 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: `--app` needs a value that is not empty; usage: tessera replay [--json] [--pretty] --app + error: `--app` needs a value that is not empty; usage: tessera replay [--json] [--pretty] --app ... """ ), [8]: CommandOutput( exitCode: 2, stdout: """ - {"command":"run","exitCode":2,"message":"`--trace` needs a value that is not empty; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] [--trace ] ..."} + {"command":"run","exitCode":2,"message":"`--trace` needs a value that is not empty; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] [--trace ...] ..."} """, stderr: "" @@ -1053,9 +1063,11 @@ struct HarnessCLIScenarioTests { let notUTF8 = await run("replay", binary, "--app", "stack", "--pretty") let notRecords = await run("replay", notTrace, "--app", "stack", "--pretty") let unknown = await run("replay", "run.trace", "--app", "ghost", "--pretty") + let appTwice = await run( + "replay", "run.trace", "--app", "stack", "--app", "counter", "--pretty") assertInlineSnapshot( - of: scratch.dump([noFile, noName, missing, notUTF8, notRecords, unknown]), + of: scratch.dump([noFile, noName, missing, notUTF8, notRecords, unknown, appTwice]), as: .lines ) { #""" @@ -1064,7 +1076,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: Missing expected argument ''; usage: tessera replay [--json] [--pretty] --app + error: Missing expected argument ''; usage: tessera replay [--json] [--pretty] --app ... """ ), @@ -1072,7 +1084,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: Missing expected argument '--app '; usage: tessera replay [--json] [--pretty] --app + error: Missing expected argument '--app ...'; usage: tessera replay [--json] [--pretty] --app ... """ ), @@ -1107,6 +1119,14 @@ struct HarnessCLIScenarioTests { stderr: """ error: unknown app `ghost`; accepted: counter, event-log, greedy, main-actor-counter, observed-counter, stack, workspace + """ + ), + [6]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--app` is given twice; usage: tessera replay [--json] [--pretty] --app ... + """ ) ] diff --git a/Tests/TesseraHarnessTests/HarnessCLITests.swift b/Tests/TesseraHarnessTests/HarnessCLITests.swift index b1948d2..ff24970 100644 --- a/Tests/TesseraHarnessTests/HarnessCLITests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLITests.swift @@ -17,6 +17,8 @@ private let completionRequests: [[String]] = [ ["launch", "--", "positional@0", "2", "0", "tessera", "launch", ""], ["shot", "--", "positional@0", "2", "0", "tessera", "shot", ""], ["tree", "--", "positional@0", "2", "0", "tessera", "tree", ""], + ["check", "--", "positional@0", "2", "0", "tessera", "check", ""], + ["replay", "--", "--app", "4", "0", "tessera", "replay", "t.jsonl", "--app", ""], ] @MainActor @@ -505,7 +507,7 @@ struct HarnessCLITests { exitCode: 2, stdout: "", stderr: """ - error: `help` takes one command, got `launch extra`; accepted: apps, launch, run, shot, tree, help + error: `help` takes one command, got `launch extra`; accepted: apps, launch, run, shot, tree, check, replay, help """ ), @@ -513,7 +515,7 @@ struct HarnessCLITests { exitCode: 2, stdout: "", stderr: """ - error: `help` takes one command, got `ghost launch`; accepted: apps, launch, run, shot, tree, help + error: `help` takes one command, got `ghost launch`; accepted: apps, launch, run, shot, tree, check, replay, help """ ) From 61f80c9eb33c0e5542d235b665df75c8d1a7b077 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 16:05:33 -0400 Subject: [PATCH 49/55] fix(dev): exit 2 from dev:check and dev:replay without arguments off a terminal --- mise-tasks/dev/check | 3 +-- mise-tasks/dev/replay | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/mise-tasks/dev/check b/mise-tasks/dev/check index 1070915..157fe47 100755 --- a/mise-tasks/dev/check +++ b/mise-tasks/dev/check @@ -10,7 +10,6 @@ set -euo pipefail cd "$MISE_PROJECT_ROOT" source scripts/dev-tessera.sh if [[ -z "${usage_name:-}" ]]; then - tessera_help_with_apps check - exit 0 + tessera_exit_with_help_and_apps check fi tessera check "$@" diff --git a/mise-tasks/dev/replay b/mise-tasks/dev/replay index 0d8efa6..f2dd06c 100755 --- a/mise-tasks/dev/replay +++ b/mise-tasks/dev/replay @@ -9,7 +9,6 @@ set -euo pipefail cd "$MISE_PROJECT_ROOT" source scripts/dev-tessera.sh if [[ -z "${usage_file:-}" ]]; then - tessera_help_with_apps replay - exit 0 + tessera_exit_with_help_and_apps replay fi tessera replay "$@" From 233dc4374a7fd6180f8decc2be71abe03adcab57 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 16:35:43 -0400 Subject: [PATCH 50/55] fix(harness): refuse a repeated single-value option before ArgumentParser parses --repeat is a plain single-value option again, so usage reads `--repeat ` and an agent does not take it for a repeatable one. HarnessCLI checks the raw arguments for each option a command lists in singleValueOptions, as `--option value` or `--option=value` up to a `--`, and rejects a second one with the usage error the command line always gave. A test compares each command's declared options with ArgumentParser's own dump, so a new option that the list misses fails it. --- .agents/skills/tessera/SKILL.md | 2 +- .../TesseraHarness/HarnessCLI+Commands.swift | 10 +++ .../TesseraHarness/HarnessCLI+Scenarios.swift | 20 +---- Sources/TesseraHarness/HarnessCLI.swift | 68 +++++++++++++- .../HarnessCLIScenarioTests.swift | 89 +++++++++++++++++-- .../TesseraHarnessTests/HarnessCLITests.swift | 22 +++++ 6 files changed, 182 insertions(+), 29 deletions(-) diff --git a/.agents/skills/tessera/SKILL.md b/.agents/skills/tessera/SKILL.md index 18c401c..796bfc5 100644 --- a/.agents/skills/tessera/SKILL.md +++ b/.agents/skills/tessera/SKILL.md @@ -112,7 +112,7 @@ The factory closure runs on the main actor once per run, so every run starts fro Every command takes `--json` or `--pretty` to force the JSON object or the text form. Standard output is one JSON object when it is not a terminal, and text when it is. `apps` prints `{"apps": [{"minimum", "name", "viewports"}], "command": "apps"}`, sorted by name, where `minimum` is `null` for an app that declares none. A failed command prints `{"command", "exitCode", "message"}`. -Exit codes: 0 success, 1 an expectation failed, 2 a usage or parse error (an unknown command, app name, or flag, a missing or extra argument, `--json` with `--pretty`, a bad path, a scenario that does not parse; the message lists the accepted commands or app names, or the command's usage), 3 a harness error (a name registered twice, `launch` without a terminal, a golden that cannot be written). `--json` and `--pretty` go after the command or before it. +Exit codes: 0 success, 1 an expectation failed, 2 a usage or parse error (an unknown command, app name, or flag, a missing or extra argument, a flag that takes a value given twice, `--json` with `--pretty`, a bad path, a scenario that does not parse; the message lists the accepted commands or app names, or the command's usage), 3 a harness error (a name registered twice, `launch` without a terminal, a golden that cannot be written). `--json` and `--pretty` go after the command or before it. ## Complete commands and app names diff --git a/Sources/TesseraHarness/HarnessCLI+Commands.swift b/Sources/TesseraHarness/HarnessCLI+Commands.swift index fbb9eef..5a2b5ad 100644 --- a/Sources/TesseraHarness/HarnessCLI+Commands.swift +++ b/Sources/TesseraHarness/HarnessCLI+Commands.swift @@ -8,11 +8,21 @@ import TesseraTerminal /// It stays apart from ArgumentParser's `run()` so that a command returns what it printed /// instead of printing it, which lets a test run one without a process. protocol HarnessCommand: ParsableCommand { + /// The options of the command that take one value and may be given at most once, spelled + /// with their dashes, such as `--repeat`. `HarnessCLI` rejects a second one before + /// ArgumentParser, which would keep the last, parses; a test fails when the command declares + /// an option that this does not list. + static var singleValueOptions: [String] { get } + /// Runs the command and returns what it printed and how it ended. @MainActor func execute(in context: HarnessCLI.Context) async -> CommandOutput } +extension HarnessCommand { + static var singleValueOptions: [String] { [] } +} + extension HarnessCLI { /// What a parsed command runs against. struct Context { diff --git a/Sources/TesseraHarness/HarnessCLI+Scenarios.swift b/Sources/TesseraHarness/HarnessCLI+Scenarios.swift index 12d2457..bc85b54 100644 --- a/Sources/TesseraHarness/HarnessCLI+Scenarios.swift +++ b/Sources/TesseraHarness/HarnessCLI+Scenarios.swift @@ -47,16 +47,6 @@ extension HarnessCLI { } } -/// Refuses a flag given more than once, as the command line always has. -/// -/// ArgumentParser takes the last value of a repeated option, so an option that must be given -/// at most once is declared as an array and checked here. -private func refuseRepeats(of flag: String, given count: Int) throws { - guard count <= 1 else { - throw ValidationError("`\(flag)` is given twice") - } -} - /// `NAME [SIZE]`, the arguments `shot` and `tree` take. struct PictureArguments: ParsableArguments { @Argument( @@ -124,6 +114,8 @@ struct RunCommand: HarnessCommand { "parse". Fields are added, never removed or renamed. """) + static let singleValueOptions = ["--repeat"] + @OptionGroup var output: OutputOptions @Flag( @@ -139,7 +131,7 @@ struct RunCommand: HarnessCommand { help: ArgumentHelp( "Run each scenario N times, and fail a run whose report differs from the first.", valueName: "n")) - var repeatValues: [Int] = [] + var repetitions = 1 @Flag( help: """ @@ -153,13 +145,7 @@ struct RunCommand: HarnessCommand { completion: .file(extensions: ["scn"])) var paths: [String] - /// The number of times to run each scenario: the one `--repeat` value, or 1. - private var repetitions: Int { - repeatValues.first ?? 1 - } - func validate() throws { - try refuseRepeats(of: "--repeat", given: repeatValues.count) guard repetitions >= 1 else { throw ValidationError( "`--repeat` needs a whole number of at least 1, got `\(repetitions)`") diff --git a/Sources/TesseraHarness/HarnessCLI.swift b/Sources/TesseraHarness/HarnessCLI.swift index 48fcbc6..0c0bd19 100644 --- a/Sources/TesseraHarness/HarnessCLI.swift +++ b/Sources/TesseraHarness/HarnessCLI.swift @@ -130,6 +130,12 @@ extension HarnessCLI { return unknown } + // ArgumentParser keeps the last of a repeated option, so a single-value option given + // twice is a usage error found here, before it parses. + if let repeated = repeatedOption(in: arguments, json: asJSON) { + return repeated + } + let parsed: any ParsableCommand do { parsed = try Root.parseAsRoot(arguments) @@ -244,15 +250,71 @@ extension HarnessCLI { } // The message says what was wrong, on one line so the JSON form reads; the usage says // what is accepted where it was. - let command = commands.first { - $0._commandName == withoutOutputFlags(arguments).first - } let message = Root.message(for: error) .split(whereSeparator: \.isNewline) .map { $0.trimmingCharacters(in: .whitespaces) } .joined(separator: " ") + return usageFailure( + message, command: commandBeingRun(in: arguments)?.type, json: json) + } + + /// The usage error for `message`, ending with the usage of `command`, or of the whole + /// command line when it names none. + private static func usageFailure( + _ message: String, command: (any HarnessCommand.Type)?, json: Bool + ) -> CommandOutput { let synopsis = command.map { Root.usageString(for: $0) } ?? Root.usageString() return usage( "\(message); usage: \(synopsis)", command: command?._commandName, json: json) } + + /// The command the arguments run and where its name stands: the first word that is not an + /// output flag, when it names a command. + private static func commandBeingRun(in arguments: [String]) + -> (index: Int, type: any HarnessCommand.Type)? + { + guard let index = arguments.firstIndex(where: { !OutputForm.spellings.contains($0) }), + let type = commands.first(where: { $0._commandName == arguments[index] }) + else { + return nil + } + return (index, type) + } + + /// The usage error for a single-value option given more than once, when the arguments hold + /// one. + /// + /// ArgumentParser keeps the last value of a repeated option. Declaring the option as an + /// array would make it reject a second one, but its usage would then read `--repeat + /// ...`, which tells a reader the option may repeat. So each command lists its + /// single-value options, and the raw arguments are checked before parsing: `--option value` + /// and `--option=value`, up to a `--`. + private static func repeatedOption(in arguments: [String], json: Bool) -> CommandOutput? + { + guard let (index, command) = commandBeingRun(in: arguments), + !command.singleValueOptions.isEmpty + else { + return nil + } + let options = command.singleValueOptions + var seen: Set = [] + var position = index + 1 + while position < arguments.count, arguments[position] != "--" { + let argument = arguments[position] + guard + let option = options.first(where: { + argument == $0 || argument.hasPrefix("\($0)=") + }) + else { + position += 1 + continue + } + guard seen.insert(option).inserted else { + return usageFailure("`\(option)` is given twice", command: command, json: json) + } + // `--option value` also consumes its value. + position += argument == option ? 2 : 1 + } + return nil + } } diff --git a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift index 1ee2dae..d21490c 100644 --- a/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLIScenarioTests.swift @@ -238,7 +238,7 @@ struct HarnessCLIScenarioTests { CommandOutput( exitCode: 2, stdout: """ - {"command":"run","exitCode":2,"message":"Missing expected argument ' ...'; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ..."} + {"command":"run","exitCode":2,"message":"Missing expected argument ' ...'; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ..."} """, stderr: "" @@ -266,7 +266,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: Unknown option '--frobnicate'; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... + error: Unknown option '--frobnicate'; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... """ ), @@ -274,7 +274,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: Missing value for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... + error: Missing value for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... """ ), @@ -282,7 +282,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: `--repeat` needs a whole number of at least 1, got `0`; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... + error: `--repeat` needs a whole number of at least 1, got `0`; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... """ ), @@ -290,7 +290,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: The value 'many' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... + error: The value 'many' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... """ ), @@ -298,7 +298,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... + error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... """ ), @@ -306,7 +306,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... + error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... """ ) @@ -315,6 +315,79 @@ struct HarnessCLIScenarioTests { } } + @Test + func `a single-value option given twice is an error however it is spelled`() async { + let equals = await run("run", "x.scn", "--repeat=2", "--repeat=3", "--pretty") + let mixed = await run("run", "x.scn", "--repeat=2", "--repeat", "3", "--pretty") + let leading = await run("--pretty", "run", "x.scn", "--repeat", "2", "--repeat=3") + let json = await run("run", "x.scn", "--repeat", "2", "--repeat", "3") + + assertInlineSnapshot(of: [equals, mixed, leading, json], as: .customDump) { + #""" + [ + [0]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + + """ + ), + [1]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + + """ + ), + [2]: CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: `--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + + """ + ), + [3]: CommandOutput( + exitCode: 2, + stdout: """ + {"command":"run","exitCode":2,"message":"`--repeat` is given twice; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ..."} + + """, + stderr: "" + ) + ] + """# + } + } + + @Test + func `after the separator a repeated option name is a path, not an option`() async { + let output = await run("run", "--pretty", "--", "--repeat", "--repeat") + + #expect(output.exitCode == 2) + #expect(!(output.stdout + output.stderr).contains("given twice")) + } + + @Test + func `usage shows a single-value option once, with its value and no ellipsis`() async { + let output = await run("run", "x.scn", "--nope", "--pretty") + + assertInlineSnapshot(of: output, as: .customDump) { + #""" + CommandOutput( + exitCode: 2, + stdout: "", + stderr: """ + error: Unknown option '--nope'; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... + + """ + ) + """# + } + } + @Test func `a path taken as the repeat count reports the repeat value, not a missing path`() async @@ -327,7 +400,7 @@ struct HarnessCLIScenarioTests { exitCode: 2, stdout: "", stderr: """ - error: The value 'x.scn' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ...] [--timing] ... + error: The value 'x.scn' is invalid for '--repeat '; usage: tessera run [--json] [--pretty] [--record] [--repeat ] [--timing] ... """ ) diff --git a/Tests/TesseraHarnessTests/HarnessCLITests.swift b/Tests/TesseraHarnessTests/HarnessCLITests.swift index f7d81b6..79d96b2 100644 --- a/Tests/TesseraHarnessTests/HarnessCLITests.swift +++ b/Tests/TesseraHarnessTests/HarnessCLITests.swift @@ -450,6 +450,28 @@ struct HarnessCLITests { #expect(output.stdout.contains("USAGE: mytool launch")) } + @Test(arguments: HarnessCLI.commandNames.filter { $0 != "help" }) + func `a command lists every option it declares as a single-value option`( + name: String + ) throws { + let command = try #require(HarnessCLI.commands.first { $0._commandName == name }) + let dump = try #require( + JSONSerialization.jsonObject(with: Data(HarnessCLI.Root._dumpHelp().utf8)) + as? [String: Any]) + let root = try #require(dump["command"] as? [String: Any]) + let subcommands = try #require(root["subcommands"] as? [[String: Any]]) + let entry = try #require(subcommands.first { $0["commandName"] as? String == name }) + let arguments = try #require(entry["arguments"] as? [[String: Any]]) + let options = arguments.filter { $0["kind"] as? String == "option" } + + // An array option would repeat, and its usage would say so. + #expect(options.allSatisfy { $0["isRepeating"] as? Bool == false }) + let spelled = options.compactMap { + ($0["preferredName"] as? [String: Any])?["name"] as? String + }.map { "--\($0)" } + expectNoDifference(Set(spelled), Set(command.singleValueOptions)) + } + @Test func `help and its errors name no repository path and promise no later work`() async { var texts = [await run("help").stdout] From a3cbacc24f2a295bbb5de7a35d11552580c2062e Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sat, 24 Oct 2026 16:42:13 -0400 Subject: [PATCH 51/55] chore: drop files the squash merge of #64 re-added Slice 4 deletes ApplicationConsumer and moves the harness's demos and registry; merging the squashed #64 re-added them. --- .../ApplicationConsumer.swift | 36 ---- Sources/TesseraHarness/Demos.swift | 161 ------------------ Sources/TesseraHarness/Registry.swift | 84 --------- 3 files changed, 281 deletions(-) delete mode 100644 Examples/Sources/ApplicationConsumer/ApplicationConsumer.swift delete mode 100644 Sources/TesseraHarness/Demos.swift delete mode 100644 Sources/TesseraHarness/Registry.swift 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/Sources/TesseraHarness/Demos.swift b/Sources/TesseraHarness/Demos.swift deleted file mode 100644 index dd51c9e..0000000 --- a/Sources/TesseraHarness/Demos.swift +++ /dev/null @@ -1,161 +0,0 @@ -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` routes no input to views. -package final class WorkspaceApp: DemoApp { - package static let name = "workspace" - - private static let catalogRows = 30 - private static let catalogText = (0..= 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) - } -} From 1f9ef5f0171aae826c4fd4e8011018d4854b1690 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sun, 25 Oct 2026 20:18:35 -0400 Subject: [PATCH 52/55] docs(plans): add the library-fixes slice and renumber plan 036 --- docs/design/primitives/text.md | 2 +- ...hase-4-view-layer-and-developer-tooling.md | 122 ++++++++++-------- 2 files changed, 67 insertions(+), 57 deletions(-) diff --git a/docs/design/primitives/text.md b/docs/design/primitives/text.md index d97a96d..017c8fa 100644 --- a/docs/design/primitives/text.md +++ b/docs/design/primitives/text.md @@ -419,4 +419,4 @@ Each is deferred to the plan step that lands it: ## Inspiration -The Text demo renders ordinary, wide, emoji, combining, half-width, and multiline Text fixtures at both viewports before it introduces focusable controls. Wrapped, budget-truncated, and mode-truncated fixtures join it when wrapping and truncation land with the styling slice (Slice 8 of [plan 036](../../plans/phase-4-view-layer-and-developer-tooling.md)); `Text` neither wraps nor truncates before then. Text has no demo-only behavior; the demo proves the public leaf through a real `ViewGraph` and `Frame`. +The Text demo renders ordinary, wide, emoji, combining, half-width, and multiline Text fixtures at both viewports before it introduces focusable controls. Wrapped, budget-truncated, and mode-truncated fixtures join it when wrapping and truncation land with the styling slice (Slice 9 of [plan 036](../../plans/phase-4-view-layer-and-developer-tooling.md)); `Text` neither wraps nor truncates before then. Text has no demo-only behavior; the demo proves the public leaf through a real `ViewGraph` and `Frame`. 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 c93ed60..e331bf8 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -20,22 +20,23 @@ This plan merges plan 032 (Phases 8-17, its open implementation phases; Phases 1 - [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. - [x] 5 Scenario language, runner, the `tessera` CLI, and the CI corpus: `3faa2f4` and `ef4592b` on `phase-4/05-scenarios` (PR #66, stacked on #65); full `mise run core:test` runs 838 tests green on Xcode 26.6 and on Xcode 27.1 (750 on the base; 88 added across the parser, player, runner, golden, report, answerer, and command tests in `TesseraHarnessTests`), `swift test --package-path Examples` runs 25 tests on both toolchains (23 on the base; `ScenarioCorpusTests` added), and `mise run quality:format`, `quality:lint`, and `docs:lint` are green; neither `Package.resolved` changed. Two runs of `tessera run Examples/Scenarios` give the same bytes (`shasum` `55fa9588…` twice, 6,996 bytes of JSON); a parse error under `--json` is a report with `"verb":"parse"` and exit 2; a drifted golden fails without `--record` (exit 1, the first differing row named) and `--record` rewrites it (`(updated)`); and the local CI probe, with `counter-basics.scn`'s `expect text 2` changed to a wrong count, exits 1 with its failure at line 19 and exits 0 once reverted (reports below). CI on `ef4592b` (run 40074331235) and on the plan record `5b4dabf` (run 40075060791) passed all 14 jobs, and every `test` leg ran `tessera run --pretty Examples/Scenarios` and printed `5 scenarios, 5 passed, 0 failed`, `test (windows-latest)` included, with its goldens checked out by Git for Windows' defaults and LF by `.gitattributes`; the first run (`3faa2f4`, run 40073722211) failed only that leg, in the README table test, because a Windows checkout of `README.md` has CRLF endings and a `"\r\n"` is one `Character` (fixed in `ef4592b`). The seven findings of the critic review of PR #66 (the app finishing, case-folded shot names, a CRLF golden that had also drifted, `run --repeat PATH`, the unnamed JSON fields, the heredoc closer, and `check`'s app name) are addressed on top of it, and the PR carries the head's run. - [x] 6 Projection, locators, invariants, cost, trace, and shots, with the first demos: `6781c57` (the library: a resize's viewport reasons survive `update()`, and `TerminalSession.bytesDrawn`), `10f8484` (the projection, rules, `expect cost`, trace, grid goldens, and the six demos with their 12 scenarios), `b2c3161` (an `expect cost` bound in the `ForEach` scenario), `a7260a7` (the critic review's fixes: list-looking `siblings-disjoint`, the grid row rule, 62 styles, `run --pretty` warnings, empty arguments, the trace version and focus events, and the Flex, Stacks, and ZStack demo gaps), and `a7d0b5c` (the re-check's fix: a view list is recognised by its module-qualified type, so an app's own `ForEach` is not one) on `phase-4/06-projection` (PR #67, stacked on #66, merged with `origin/phase-4/05-scenarios` at `1cd10ea`); full `mise run core:test` runs 959 tests green on Xcode 26.6 and on Xcode 27.1 (838 on the base; 118 added in `TesseraHarnessTests` across the type-name, grid, projection, rule, statement, trace, replay, and command tests, and two `bytesDrawn` tests and one resize-reason test in the library), `swift test --package-path Examples` runs 39 tests on both toolchains (25 on the base; `PrimitiveDemoTests` added and the corpus floor raised to 16), and `mise run quality:format`, `quality:lint`, and `docs:lint` are green (the pre-commit hook's lint of `Examples/Tests` too); neither `Package.resolved` changed, and `git diff --name-only origin/phase-4/05-scenarios...HEAD` lists none. `mise run dev:run Examples/Scenarios` runs 16 scenarios, 16 passed, and two runs give the same bytes (`shasum` `968b46d6…` twice, 34,563 bytes of JSON; `--repeat 3` agrees); `siblings-disjoint` passes on the `foreach` demo after items are added, removed, and reversed (`check` after each step of `foreach-desktop.scn`), and on `zstack` with layers from a `ForEach` and an `if` (it fails there with the list-looking removed), while an app's own view named `ForEach` whose body is `Text; Text` under a `ZStack` is reported (a live test that fails under short-name matching). `tessera check foreach` exits 0 and names its five rules; `tessera check workspace mobileScreen` exits 1 with `error measured-within-proposal root/index(2)/id(catalog)/index(0)/index(0): measured 7 columns where 0 were offered` (Discoveries); tightening `foreach-desktop.scn`'s `expect cost bytes<=1000` to `<=100` fails with `step 1 (key +) took 430 bytes, above the bound 100`. `tessera run --trace` writes five records for `foreach-desktop.scn`, and every redraw names its reasons (`layoutEnvironmentChanged`, `layoutViewChanged`, `renderEnvironmentChanged`, `renderRequested`, `renderViewChanged`, `updateRequested`), with 2,577 bytes for the first frame and 430, 81, 473, and 457 for the four steps after it. CI on `b2c3161` (run 40083068340), on the plan record `5b8b4c1` (run 40083937741), and on the head after the critic fixes `6ea1bd5` (run 40087437615) passed all 14 jobs, and every `test` leg printed `16 scenarios, 16 passed, 0 failed`, `test (windows-latest)` included, with the new grid goldens checked out as LF; the PR carries the head's run after the re-check's fix. -- [ ] 7 Flow layout and `ViewThatFits` -- [ ] 8 Styling and decoration, with pictures, roles, and catalog review -- [ ] 9 Focus, key routing, and KeyLegend, with focus tooling -- [ ] 10 Mouse, hit testing, and hover, with pointer tooling -- [ ] 11 Interactive ScrollView, with scroll checks -- [ ] 12 Roles and labels in the projection -- [ ] 13 Controls -- [ ] 14 Collections -- [ ] 15 Interactive SplitView -- [ ] 16 The animation path and the schedulable clock -- [ ] 17 Remaining 1.0 views -- [ ] 18 Live inspection -- [ ] 19 Docs, skill, and the conformance gate -- [ ] 20 Intro app and Phase 4 closure - -Next: Slice 1 in its own pull request (PR #60 is open). Slices 2, 3, 4, 5, and 6 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`; PR #66, stacked on #65: the scenario language, the runner, `tessera run`, `shot`, and `tree`, and the CI corpus; PR #67, stacked on #66: the projection, locators, rules, `expect cost`, the trace, grid goldens, and the first demos). Slice 7 follows Slice 6 and needs the design catalog's PR #62 merged first; it can then branch from `phase-4/06-projection` until #59, #64, #65, #66, and #67 merge. The owner approved the Slice 3 decisions that were marked "(owner to weigh)" on 2026-10-24; the Slice 4-6 decisions marked "(owner to weigh)" are reversible and do not block it. Decision 9 (i) is recorded below, so nothing in Slice 7 waits on the owner. +- [ ] 7 Library fixes from the first demos +- [ ] 8 Flow layout and `ViewThatFits` +- [ ] 9 Styling and decoration, with pictures, roles, and catalog review +- [ ] 10 Focus, key routing, and KeyLegend, with focus tooling +- [ ] 11 Mouse, hit testing, and hover, with pointer tooling +- [ ] 12 Interactive ScrollView, with scroll checks +- [ ] 13 Roles and labels in the projection +- [ ] 14 Controls +- [ ] 15 Collections +- [ ] 16 Interactive SplitView +- [ ] 17 The animation path and the schedulable clock +- [ ] 18 Remaining 1.0 views +- [ ] 19 Live inspection +- [ ] 20 Docs, skill, and the conformance gate +- [ ] 21 Intro app and Phase 4 closure + +Next: Slice 1 in its own pull request (PR #60 is open). Slices 2, 3, 4, 5, and 6 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`; PR #66, stacked on #65: the scenario language, the runner, `tessera run`, `shot`, and `tree`, and the CI corpus; PR #67, stacked on #66: the projection, locators, rules, `expect cost`, the trace, grid goldens, and the first demos). Slice 7 (the library fixes from the first demos) follows #67 and can branch from `phase-4/06-projection` until #59, #64, #65, #66, and #67 merge; Slice 8 (Flow and `ViewThatFits`) follows it and needs the design catalog's PR #62 merged first. The owner approved the Slice 3 decisions that were marked "(owner to weigh)" on 2026-10-24; the Slice 4-6 decisions marked "(owner to weigh)" are reversible and do not block them. Decision 9 (i) is recorded below and the owner decided Q14-Q16 on 2026-10-25, so nothing in Slices 7 and 8 waits on the owner. ## Approach @@ -57,7 +58,7 @@ Open questions: Decision 9 (iii) below (how an app gets `tessera`); (i) and (ii) ### 1 Terminal colour reports -The terminal half of [terminal color reports](../spec/phase-3/slice-06-terminal-capability-detection.md#terminal-color-reports) (spec Phase 3 Slice 6, the 2026-10-19 amendment) and the [probe contract](../design/tokens.md#probe) in the tokens doc: the parser gains an OSC reply path (`rgb:` and `rgba:` bodies, 1 to 4 hex digits per channel scaled to 8 bits, BEL or ST, OSC 4 slots in any order, consumed as probe evidence and never delivered as keys); the active generation sends OSC 10, OSC 11, and OSC 4 for slots 0-15 as one write ended by the round's single DA1; the round's timeout default rises to 1 s; `NO_COLOR` asks only OSC 11; `.passive` and `.disabled` detection send no colour queries; the first frame waits for the round; late replies update the report; mode 2031 is enabled at startup with `CSI ? 2031 l` registered for cleanup, and each `CSI ? 997 ; 1 n` or `; 2 n` notification and each resume re-runs the round; replies fill `TerminalCapabilities.colorReport`. Resolving colour roles from the report (the token tiers) is Slice 8's, not this slice's. This slice depends on no other slice and uses only the existing terminal test support (`InMemoryTerminalDevice` with scripted replies), so it runs in parallel with Slices 2-7. +The terminal half of [terminal color reports](../spec/phase-3/slice-06-terminal-capability-detection.md#terminal-color-reports) (spec Phase 3 Slice 6, the 2026-10-19 amendment) and the [probe contract](../design/tokens.md#probe) in the tokens doc: the parser gains an OSC reply path (`rgb:` and `rgba:` bodies, 1 to 4 hex digits per channel scaled to 8 bits, BEL or ST, OSC 4 slots in any order, consumed as probe evidence and never delivered as keys); the active generation sends OSC 10, OSC 11, and OSC 4 for slots 0-15 as one write ended by the round's single DA1; the round's timeout default rises to 1 s; `NO_COLOR` asks only OSC 11; `.passive` and `.disabled` detection send no colour queries; the first frame waits for the round; late replies update the report; mode 2031 is enabled at startup with `CSI ? 2031 l` registered for cleanup, and each `CSI ? 997 ; 1 n` or `; 2 n` notification and each resume re-runs the round; replies fill `TerminalCapabilities.colorReport`. Resolving colour roles from the report (the token tiers) is Slice 9's, not this slice's. This slice depends on no other slice and uses only the existing terminal test support (`InMemoryTerminalDevice` with scripted replies), so it runs in parallel with Slices 2-8. Delivers: a session against a terminal that answers fills `colorReport` before the first frame, and one that does not leaves every input unknown without delaying past the timeout. Verify: the spec's test list for the round (one write, both reply forms and terminators, DA1 or timeout ending, late replies as updates, OSC 11 only under `NO_COLOR`, notification and resume re-runs, cleanup bytes); the Windows input path parses the same replies (`mise run vm:windows-test`); the spec section's "not yet implemented" note is removed in the same change. @@ -94,7 +95,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` 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. +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 10'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. @@ -104,7 +105,7 @@ Isolation is pinned by two compile-fail fixtures beside `Tests/CompileFailFixtur Create the two skills: `.agents/skills/tessera/SKILL.md`, the published skill for app authors, with what this slice makes usable (the `Application`, `ApplicationRegistry`, and `HarnessCLI.main(registry:)` wiring, `tessera apps`, `tessera launch NAME`), and `.agents/skills/tessera-contributing/SKILL.md`, the in-repo skill for contributors, with the `mise run dev:*` tasks, the demos, and a demo's headless snapshot test; and an `AGENTS.md` pointer to both. Later slices extend the `tessera` skill first and the contributor skill after it. -Delivers: one demo steps through one driver, headless and live: `tessera launch NAME` runs it in a terminal and a snapshot test renders it identically through the in-memory device. Verify: registry tests cover lookup and duplicates (every reading entry point fails); a greedy demo fails `size mobile`; one demo's snapshot test is a few lines, and it is the test that renders a `ViewGraph` through `TerminalSession.draw` into an `InMemoryTerminalDevice` and asserts the bytes the device received, which restores the coverage the retired Showcase's `ShowcaseTerminalSessionTests` gave; a nonisolated demo steps on a per-test actor and on the main actor through the same `Step.run`; both compile-fail fixtures fail with their expected diagnostics (the second one only in plain Swift 6 mode: Slice 3 checked it in a scratch two-module package on Swift 6.3.3 and 6.4, where the line is an error without `InferIsolatedConformances`, builds with default main-actor isolation in the extension's module, and builds with `InferIsolatedConformances` on 6.4 but still errors on 6.3.3 for a type isolated only through its module's default isolation), their controls compile, and `mise run quality:architecture` runs them; the `.resize` rule is in the step's doc comment and Slice 9's routing tests pin it once `dispatch` can consume events. `DemoSupport` re-adds the `Tessera` product to the Examples manifest and so replaces `Examples/Sources/ApplicationConsumer`, the one-file guard Slice 3 added for `import Tessera` from outside the root package; delete it here. +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 10'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 @@ -118,85 +119,91 @@ Plan 033 Step B.4 and plan 032 Step 8.3. `Projection` (sorted-key JSON of `Graph 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). -### 7 Flow layout and `ViewThatFits` +### 7 Library fixes from the first demos -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. +Owner decisions Q14-Q16 (2026-10-25). The Slice 6 demos exposed library defects, recorded in Discoveries: a graph rooted at an `@ViewBuilder` body is dirty after every `update()`, every step of `stacks`, `flex`, and `foreach` reports `layoutEnvironmentChanged`, a `Spacer` with `layoutPriority(1)` takes the whole remainder, `Flex` fill weights invert when its children are larger than the space, and `Flex` takes no alignment. The demos work around or pin them today, so they are fixed here, right after Slice 6 and before Flow and `ViewThatFits` build on the same layout code; the harness still never compensates for a library bug. Five fixes in one reviewable step. (i) A graph rooted at an `@ViewBuilder` body is clean after an `update()` that changes nothing: the builder wraps even one view in a `TupleView`, which is not equatable, so the root is re-evaluated and marked every time, and the slice makes reconciliation skip an unchanged root. (ii) Reconciliation stops reporting `layoutEnvironmentChanged` and `renderEnvironmentChanged` for an unchanged environment: stacks rebuild their children's `EnvironmentValues` on each update and reconciliation compares storage by identity (`===`); the slice chooses between comparing by value and keeping one storage across updates, and records the choice under Decisions. (iii) Q15: a higher `layoutPriority` tier receives the remaining space minus the minimums of all lower-priority children (SwiftUI's rule), not the whole remainder, so `HStack { L; Spacer().layoutPriority(1); M; Spacer(); R }` keeps `M` and `R` on screen. The slice amends step 3 of `docs/spec/phase-4/slice-02-layout-protocol-and-stacks.md` and its priority-tier distribution tests in the same change; the amended wording says how rigid children, the reserved minimums, and the integer remainder rule interact, and the slice notes in Discoveries whether Flex's own tier rule needs the same reservation. (iv) `Flex` fill weights stop inverting when the children are larger than the space: the slice settles the rule against the negative-remainder text of the Flex spec (`docs/spec/phase-4/slice-02-5-flex-and-splitview-negotiation.md` and `docs/design/primitives/flex.md`: `fill` allocations are removed by weight toward zero, equal-tier remainder cells from later children first) and amends whichever of the code and the text is wrong. (v) Q16: `Flex` gains `alignment: FlexAlignment = .start` with cases `.start`, `.center`, and `.end`, the cross-axis placement of a child smaller than the cross extent, relative to the axis, like CSS `align-items`; there is no `stretch` and no main-axis justification for now. The default keeps today's frames (`.start` is the leading edge: `VerticalAlignment.top` for a horizontal `Flex`, `HorizontalAlignment.leading` for a vertical one), and `docs/design/primitives/flex.md` (its cross-axis placement sentence and public initializer) and the Flex spec are updated in the same change. `Flex`'s `min(n)` floor on a non-wrapping `Text` child stays with Text wrapping (Slice 9). -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. +Delivers: a graph is clean after an idle step, an unchanged environment is not reported as changed, a high-priority `Spacer` no longer pushes lower tiers past the edge, oversized `Flex` fill weights keep their order, and `Flex` aligns children on its cross axis. Verify: an idle step of the `stacks`, `flex`, and `foreach` demos reports no environment reasons and draws nothing (`drew: false`, no bytes); the `stacks` demo drops the neighbour priorities it uses as a workaround; the `flex` demo shows `.center` and `.end`, and its scenario pins the frames; distribution-table tests cover the reserved minimums and oversized fill weights; goldens change only where these fixes move frames. -### 8 Styling and decoration, with pictures, roles, and catalog review +### 8 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 10 and 11 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 10 adds `focus-resolves` to that scenario. + +### 9 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), 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. -### 9 Focus, key routing, and KeyLegend, with focus tooling +### 10 Focus, key routing, and KeyLegend, with focus tooling 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`. Once `dispatch` can consume events, a routing test pins Slice 4's rule that the app still sees every resize and only the other events the graph left `.ignored`. 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 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. -### 10 Mouse, hit testing, and hover, with pointer tooling +### 11 Mouse, hit testing, and hover, with pointer tooling 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. -### 11 Interactive ScrollView, with scroll checks +### 12 Interactive ScrollView, with scroll checks 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. -### 12 Roles and labels in the projection +### 13 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 13 lands. +Delivers: locators address controls by meaning. Verify: a scenario finds a Button by `role=button label="Submit"` before Slice 14 lands. -### 13 Controls +### 14 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. -### 14 Collections +### 15 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. -### 15 Interactive SplitView +### 16 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. -### 16 The animation path and the schedulable clock +### 17 The animation path and the schedulable clock 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. -### 17 Remaining 1.0 views +### 18 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. -### 18 Live inspection +### 19 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 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). The protocol carries a version and is outside the 1.0 promise. 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. -### 19 Docs, skill, and the conformance gate +### 20 Docs, skill, and the conformance gate -Plan 033 Phase H. The final review and revision of both skills that Slice 4 created and each tooling slice extended: the published `tessera` skill (the loop: `tessera launch` → `run` → read JSON → `shot` → `tree` → trace → `check` → fix the app, or report a Tessera bug and never work around it; never `--record` to make a failing run pass) and the in-repo `tessera-contributing` skill (the `mise` tasks, the demos, and fix the library, never the demo); how the `tessera` skill is distributed is settled with Decision 9 (iii); `docs/guides/developer-tools.md` (the human how-to for Tessera contributors and app authors, linking [Developer tooling](../architecture/developer-tooling.md) for the design rather than repeating it); the app-author path from Decision 9 (an app declares `: Application` where the type is declared, or `extension MyApp: Application {}` (`: @MainActor Application` for a main-actor type) in a test or dev target; adds a dev executable whose body is `HarnessCLI.main(registry:)`; registers its apps with `@MainActor` factories; writes scenarios, runs them from `swift test` through `ScenarioRunner`, and runs `check` in its own CI; the guide shows the four adoption shapes of the spec's Runtime isolation section and says that an app's own hand-written loop must match the canonical step until it adopts `ApplicationRuntime`); [Developer tooling's Stability section](../architecture/developer-tooling.md#stability) checked against the shipped `Examples/Scenarios/README.md`, the CLI, and the report types; the spec's diagnostics paragraph checked against the amendments Slices 12 and 18 made; and the doc drift `docs/research/tui-devtools-prior-art.md` names fixed. `mise run ci:check` and CI run `conform` on `implemented` docs. +Plan 033 Phase H. The final review and revision of both skills that Slice 4 created and each tooling slice extended: the published `tessera` skill (the loop: `tessera launch` → `run` → read JSON → `shot` → `tree` → trace → `check` → fix the app, or report a Tessera bug and never work around it; never `--record` to make a failing run pass) and the in-repo `tessera-contributing` skill (the `mise` tasks, the demos, and fix the library, never the demo); how the `tessera` skill is distributed is settled with Decision 9 (iii); `docs/guides/developer-tools.md` (the human how-to for Tessera contributors and app authors, linking [Developer tooling](../architecture/developer-tooling.md) for the design rather than repeating it); the app-author path from Decision 9 (an app declares `: Application` where the type is declared, or `extension MyApp: Application {}` (`: @MainActor Application` for a main-actor type) in a test or dev target; adds a dev executable whose body is `HarnessCLI.main(registry:)`; registers its apps with `@MainActor` factories; writes scenarios, runs them from `swift test` through `ScenarioRunner`, and runs `check` in its own CI; the guide shows the four adoption shapes of the spec's Runtime isolation section and says that an app's own hand-written loop must match the canonical step until it adopts `ApplicationRuntime`); [Developer tooling's Stability section](../architecture/developer-tooling.md#stability) checked against the shipped `Examples/Scenarios/README.md`, the CLI, and the report types; the spec's diagnostics paragraph checked against the amendments Slices 13 and 19 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 `tessera` skill drives an app, reads a failure, and produces a shot without reading source, with the `tessera-contributing` skill checked separately by a fresh agent that adds a demo and its snapshot test; an app outside this repository's package graph runs a scenario with `check` against a released or tagged Tessera. Verify: both agent runs, recorded in this plan; the external app's CI run; CI fails on an unpinned `implemented` wireframe (probe and revert). -### 20 Intro app and Phase 4 closure +### 21 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. @@ -225,7 +232,7 @@ Rules for an agent running slices of this plan without the owner. - [ ] `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, declares `Application`, adopts the harness, and runs a scenario with `check` in its own CI. - [ ] The stable surface the 2026-10-23 decision lists is public and documented in [Developer tooling](../architecture/developer-tooling.md#stability), and every other declaration in `TesseraHarness` is `package` or internal. -- [ ] 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. +- [ ] By Slice 10, focus is addressable, traced with cause, invariant-checked, and coverage-walked; by Slice 14, control demos use semantic locators; by Slice 21, `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. @@ -240,13 +247,13 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-21 Plan 032 Step 10.1 wires the dead `focusChanges` counter; the role and label hook lands before the first control; the environment clock is a `Clock` the harness can advance. (owner, plan 035 Step 6.1) - 2026-10-22 Flow and `ViewThatFits` (plan 032 Step 14.1) move before the controls, after focus and hit testing. (this plan, from the 2026-10-21 owner review note under plan 032 Step 13.2) Superseded 2026-10-23 by Decision 2 below. - 2026-10-22 Decision 1, the `mobile` viewport: (b), 40 columns at the component's natural height, with `mobileScreen` (40x16) declared per demo for screen-filling compositions; the harness derives natural height with the package nil-height probe and two-height detection, and a height-greedy demo fails `size mobile` naming `mobileScreen`. This confirms the 2026-10-10 catalog decision; `docs/design/templates/widget.md`, `docs/design/widgets/stepper.md`, and Slice 4 were brought into line, and plan 032 Step 8.2 stays as history in the cancelled plan. (owner, plan 035 Step 6.2) -- 2026-10-22 The tools' design lives in `docs/architecture/developer-tooling.md`, written before Slice 2 from plan 033 and the spikes, without plan or slice references; each slice that touches a tool keeps it and the `tessera-devtools` skill current before the slice is checked off, and a tool that outgrows its section moves to its own page under `docs/architecture/developer-tooling/`. The skill is created in Slice 4 so the agents building later slices use it, and Slice 19 is its final review. Superseded 2026-10-24 by the skill split below. Demos show their component working (interactive, every variable style, extra controls allowed for complex components) rather than the archived "teach rather than dump" bar. (owner) -- 2026-10-22 Decision 3, live inspection: (b). Amend the spec so a dev runner outside `TesseraCore` may expose an opt-in, local, same-user endpoint that serializes the harness projection and injects input through the normal step path; `TesseraCore` still serializes nothing. Slice 18 is no longer optional. (owner) -- 2026-10-22 Decision 4, `role` and `label`: (b) with (a) as the fallback. Library controls set optional, value-typed fields through an internal hook (spec amendment in Slice 12); nodes without them derive role from `type` and label from screen text. No public label modifier in 1.0; the fields are shaped for a later accessibility or Tern Surface Protocol export, which the owner wants eventually so a Tessera app can render natively in Tern or a similar host. (owner) +- 2026-10-22 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 20 is its final review. Superseded 2026-10-24 by the skill split below. Demos show their component working (interactive, every variable style, extra controls allowed for complex components) rather than the archived "teach rather than dump" bar. (owner) +- 2026-10-22 Decision 3, live inspection: (b). Amend the spec so a dev runner outside `TesseraCore` may expose an opt-in, local, same-user endpoint that serializes the harness projection and injects input through the normal step path; `TesseraCore` still serializes nothing. Slice 19 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 13); 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) -- 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 2, Flow and `ViewThatFits`: they get their own slice (Slice 8) 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 10 and 11. (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 10, 11, 12, and 17 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) @@ -271,14 +278,14 @@ Rules for an agent running slices of this plan without the owner. - 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 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 10 and 11 add the reconciliation when focus and pointer input exist, in the one `Driver` both hosts share. (this plan, Slice 4 run) - 2026-10-24 The registry and demo shapes. The two spike fixtures (`StackApp`, `WorkspaceApp`) moved out of the library into `Tests/TesseraHarnessTests/Fixtures.swift` as `Application` types, with a greedy fixture, counters of each adoption shape, and a still app, so `TesseraHarness` no longer imports `TesseraLayout` or `TesseraWidgets` and its allowlist row dropped them. `Demo` requires `name`, `minimum`, `viewports`, `fixtures`, and `init()` with no defaults; a `DemoFixture` is a name and a main-actor factory that inherits the demo's minimum and viewports; `Demos.registry()` is the one list. The counter (32x5 minimum, `desktop` and `mobile`) quits on `q` through `isQuitKey`, which compares code, modifiers, and kind, and has one fixture (`counter-high`). `ActorHost` is the per-test actor shell; its initializer is `async`, because a synchronous actor initializer is nonisolated and cannot pass `self` as the step's isolation. The driver, the three hosts, the natural-height probe, the headless device, and the stream shim are internal rather than `package` (critic review of PR #65, F2): nothing outside `TesseraHarness` needs them, since the Examples tests go through `withHeadlessHost` and the `@testable` import reaches internal declarations anyway; the step, the registry's reads, `ScenarioHost`, and `withHeadlessHost` are `package`. If a later slice needs a host from another target, widening it is one keyword. (this plan, Slice 4 run) - 2026-10-24 The Examples tests run in CI from this slice (critic review of PR #65, F4). `ci.yml` gained a `Test Examples` step after `Test` (`swift test --package-path Examples`, on every leg), and `ci:build-test` the same line, so the counter's snapshot tests and the quit-key tests cannot regress unseen; Slice 5 adds the scenario corpus run to it. (this plan, Slice 4 run) -- 2026-10-24 Slice 5 refuses what it cannot honour, by name (owner to weigh). The slice's grammar sentence lists `dump`, `expect cell`, `expect cost`, and `check`, and its CLI sentence lists `check` and `replay`, while Slice 6 builds their engines (the projection, the cost counters, the invariant rules, the trace) and Slice 8 the roles. Executing them here would be a stub, and a bare `check` over zero rules would pass vacuously, which a scenario author reads as assurance. So the README's "Not yet expressible" table holds `wait`, `settle`, `focus`, `mouse`, `hit`, `dump`, `check`, `expect cell|cost|focused|wireframe`, `expect node SEL visible|clipped|count N`, `shot NAME SEL`, `tree SEL`, `term glyphs=|keyboard=|theme=probe`, each a parse error that says what it waits for, and `ScenarioGrammar.notYetExpressible` is the same list, held to the table by a test. `tessera check` and `tessera replay` are real commands with real help and real usage errors that exit 2 with "not yet available"; Slice 6 replaces the refusals. Superseded 2026-10-24 for the commands and the refusal text by the help-text entry below. (this plan, Slice 5 run) +- 2026-10-24 Slice 5 refuses what it cannot honour, by name (owner to weigh). The slice's grammar sentence lists `dump`, `expect cell`, `expect cost`, and `check`, and its CLI sentence lists `check` and `replay`, while Slice 6 builds their engines (the projection, the cost counters, the invariant rules, the trace) and Slice 9 the roles. Executing them here would be a stub, and a bare `check` over zero rules would pass vacuously, which a scenario author reads as assurance. So the README's "Not yet expressible" table holds `wait`, `settle`, `focus`, `mouse`, `hit`, `dump`, `check`, `expect cell|cost|focused|wireframe`, `expect node SEL visible|clipped|count N`, `shot NAME SEL`, `tree SEL`, `term glyphs=|keyboard=|theme=probe`, each a parse error that says what it waits for, and `ScenarioGrammar.notYetExpressible` is the same list, held to the table by a test. `tessera check` and `tessera replay` are real commands with real help and real usage errors that exit 2 with "not yet available"; Slice 6 replaces the refusals. Superseded 2026-10-24 for the commands and the refusal text by the help-text entry below. (this plan, Slice 5 run) - 2026-10-24 Scenario files end in `.scn`, and a scenario's goldens are `NAME.txt` in a directory named like the file without its extension (owner to weigh). The architecture page said `.scenario` while `.gitattributes` and the spike said `.scn`, and "beside the scenario" put two scenarios' `shot desktop` on one file. `counter-basics.scn` keeps `counter-basics/reset.txt`, so a shot name is unique only within its scenario, `run` needs no rule to tell a golden from a scenario, and the one extension is the one `.gitattributes` already sets to LF (`Examples/Scenarios/**/*.txt` joins it). The flat alternative is `STEM.NAME.txt`; it costs one function. A golden is the screen with each row's trailing spaces removed, trailing empty rows dropped, rows joined by `\n`, and one final `\n`; comparison is byte for byte, a missing golden fails like drift (`--record` creates it), and a CRLF golden fails with a message that says to check it out with LF. (this plan, Slice 5 run) - 2026-10-24 Exit codes come from failure verbs, and the JSON shapes are the promise (owner to weigh). A failure's `verb` is the statement's own (`expect text`, `expect screen`, `expect node`, `shot`, and the input verbs), `size` (the app cannot take the size), `repeat` (a run differed), `parse`, `usage`, or `harness`; exit 1 is any of the first group, 2 is `parse` or `usage`, 3 is `harness` (a duplicate registration, an event the host could not deliver, a golden that could not be written), and the highest of a run wins, so an agent can derive the code from the JSON it already holds. `run` writes `{"command", "exitCode", "scenarios": [REPORT...]}` and a failure of the command itself `{"command", "exitCode", "message"}`, as `apps` and `launch` do; a REPORT is `{"failures", "observations", "passed", "scenario"}` with `recorded` only when `--record` wrote goldens and `seconds` only under `--timing`; a failure is `{"line", "statement", "verb", "message"}` with `expected`, `actual`, and `screen` when it compares values (a missing or CRLF golden leaves out `screen`). A `parse` failure's `message` is the bare message, with the offending line in `statement`, and the human form prints `FILE:LINE: error: message`. `ScenarioReport` and `ScenarioFailure` are not `Encodable` publicly, so the promised surface is the fields and not a conformance. For `shot` and `tree`, a size the app cannot take is exit 2 (a mistake in the command) where a scenario's `size` line is exit 1 (an expectation that failed). (this plan, Slice 5 run) - 2026-10-24 The `app` header stays required (settles the Slice 4 question): a registry is a value, a one-app registry may grow, and the line costs nothing. The runner reads a leading byte order mark and a CRLF file, and splits lines on the `\n` scalar so the `"\r\n"` character cannot hide a line end. -- 2026-10-24 The `term` header builds `colors=none|16|256|true` and `theme=light|dark` and refuses `glyphs=`, `keyboard=`, and `theme=probe` (owner to weigh). `colors` becomes the session's forced colour capability; the library has no ASCII glyph fallback, no keyboard-protocol-dependent routing, and no roles yet, so those three would be accepted and ignored, which the grammar never does. No colour exists in the library to observe `colors` either (styling is Slice 8), so it is pinned by its mapping tests. The harness answers a session's colour queries from the header (Decision 5): the headless device parses what the session writes, answers OSC 10, 11, and 4 from fixed values (`dark` `#d4d4d4` on `#1e1e1e`, `light` `#1e1e1e` on `#ffffff`, xterm's slots 0-15 for both) echoing the query's terminator, answers DA1 always so a probe round ends at once, and answers nothing else; without a `theme` it answers DA1 only, which is "colours unknown". The headless session still detects capabilities passively, and the colour round is Slice 1's (PR #60, not in this branch), so nothing asks yet: the answering is pinned at the device with the exact bytes of Slice 1's `colorQueryBytes`, and switching the headless host to active detection is one line once #60 merges, when Slice 8 first reads colours. (this plan, Slice 5 run) +- 2026-10-24 The `term` header builds `colors=none|16|256|true` and `theme=light|dark` and refuses `glyphs=`, `keyboard=`, and `theme=probe` (owner to weigh). `colors` becomes the session's forced colour capability; the library has no ASCII glyph fallback, no keyboard-protocol-dependent routing, and no roles yet, so those three would be accepted and ignored, which the grammar never does. No colour exists in the library to observe `colors` either (styling is Slice 9), so it is pinned by its mapping tests. The harness answers a session's colour queries from the header (Decision 5): the headless device parses what the session writes, answers OSC 10, 11, and 4 from fixed values (`dark` `#d4d4d4` on `#1e1e1e`, `light` `#1e1e1e` on `#ffffff`, xterm's slots 0-15 for both) echoing the query's terminator, answers DA1 always so a probe round ends at once, and answers nothing else; without a `theme` it answers DA1 only, which is "colours unknown". The headless session still detects capabilities passively, and the colour round is Slice 1's (PR #60, not in this branch), so nothing asks yet: the answering is pinned at the device with the exact bytes of Slice 1's `colorQueryBytes`, and switching the headless host to active detection is one line once #60 merges, when Slice 9 first reads colours. (this plan, Slice 5 run) - 2026-10-24 The ported corpus runs against Tessera's own registry, so the spike's two apps became demos (owner to weigh): `Examples/Sources/DemoSupport/Stack.swift` (`stack`, 20x6 minimum, `desktop` and `mobile`) and `Workspace.swift` (`workspace`, 40x10 minimum, `desktop` and `mobileScreen`), with the spike's key-name helper written out in `Workspace`, since the harness's is internal. The four passing scenarios moved with `git mv` and their text unchanged; `counter-basics.scn` is new and uses `term`, `expect screen`, `bytes`, and `paste`. `Tests/TesseraHarnessTests` keeps its own `StackApp` and `WorkspaceApp` as fixtures and `failing/mismatch.scn`, which must not sit under `Examples/Scenarios` because CI runs that directory green; Slice 6's per-primitive demos may absorb the two demos. The counter's tests stay on `withHeadlessHost`, which reads `isFinished` and `size` that no statement can yet; `ScenarioCorpusTests` runs the corpus through the public `ScenarioRunner`, the route an app author uses. (this plan, Slice 5 run) - 2026-10-24 `--repeat N` records on its first run only and compares every later run's report (with `recorded` and `seconds` removed) with the first, so a divergence is a `repeat` failure, exit 1, and a nondeterministic app shows up at the golden as well. `--timing` adds `seconds`, one rounded number per run, the only field that differs between two runs. `ShotArtifact` and the spike's `--out` are gone: goldens are the artefact, and `.build/tessera/` stays unused until pictures exist. A `\xHH` escape in a double-quoted word is ASCII only (00-7f), so a scenario never builds invalid UTF-8; a zero-scenario directory or a path that is not a `.scn` file is a usage error, so a wrong path in CI cannot pass for a green run. (this plan, Slice 5 run) - 2026-10-24 CI runs `tessera run --pretty Examples/Scenarios` as a step of the `test` job, so every leg of its matrix runs it, the three required names (`macos-26`, `ubuntu-latest`, `windows-latest`) and the Swift 6.4 lanes included, and no new required check name is needed (Decision 7); `--pretty` keeps the log readable. `ci:build-test` runs the same line. (this plan, Slice 5 run) @@ -288,20 +295,23 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-24 `text=` belongs to a leaf by the cells it shows, not by frame containment, and `id=` takes the app's own id or else a full path (owner to weigh). `text="S"` matches the leaf whose own cells show S: a container has no text, and text that a later layer paints over matches nothing, because a cell belongs to the last leaf in paint order that covers it. `id=X` is the `X` of `.id(X)` or of a `ForEach` key, or else the node's full identity path; `layout=NAME` is the short algorithm name of a `_LayoutContainer` (`_FrameLayout`, `_PaddingLayout`, or an app's own `Layout` type). A selector that matches nothing, or several without `nth=`, fails and names what is present. (this plan, Slice 6 run) - 2026-10-24 The trace is one sorted-key JSON object per step, and its switch is a flag (owner to weigh; the slice names the record and not the switch). `tessera run --trace DIR` writes `DIR/PATH.trace.jsonl`, mirroring the scenario's path with parent components dropped so a file stays inside DIR, and a live session writes the file `TESSERA_TRACE` names (exit 3 before the terminal changes mode when it cannot). A record has `version`, `seq` (0 is the first frame), the `event` (a key, paste, or resize in the scenario grammar's words, or `other`), `drew`, the invalidation `reasons`, the `nodes` added, changed, and removed, the screen `rows` that changed, and the `cost`. `tessera replay FILE --app NAME` runs the events again at the recorded size and compares reasons, nodes, rows, and cost but not bytes, which belong to the terminal; a trace holding an `other` event (a mouse event, a terminal reply) is a usage error (exit 2), since the grammar cannot say it yet. Neither the flag nor the record is promised: the record carries a `version` and may change in any release. (this plan, Slice 6 run) - 2026-10-24 `expect cost NAME<=N…` bounds the last step the scenario took, and only with `<=` (owner to weigh). The counters are `bodies`, `bytes`, `measures`, `placements`, and `renders`; the step is the last event delivered, so the statement can follow a `shot` or an `expect`; a failure prints the whole record (`bodies=0 bytes=430 measures=143 placements=34 renders=18`). `bytes` is the size of the step's frame, read from a new `package` property, `TerminalSession.bytesDrawn`, which the session sets when a draw commits, so the harness needs no lock and no output tap. The counts are the library's work and not a promise, so the corpus bounds them loosely (`foreach-desktop.scn` allows 1,000 bytes for a step that takes 430). (this plan, Slice 6 run) -- 2026-10-24 `dump` and the grid golden (owner to weigh). `dump` puts the projection in the step's observation as a `projection` object, with `lines` empty; the projection is the graph's diagnostics as sorted-key JSON (per node: path, parent, children, depth, paint order, short `type` and `layout`, proposal, measured size, frame, clip, dirty flags, environment overrides, handler kinds, requirements, and the leaf's `text`), the statistics and invalidation reasons without durations, and the screen's rows and style runs, at `version` 1. `NAME.grid.txt` is the catalog's grid dialect over that screen: `── chars ──`, `── style ──` (a letter under each covered cell, `.` for the default, trailing default cells left off), and `── legend ──`; colours are `#rrggbb` or `indexed(N)`, and attributes are `bold`, `dim`, `italic`, `underline` (`underline=curly` and the other styles), `reverse`, and `strikethrough`. The virtual terminal exposes no wide flag, so a blank cell right after a width-2 grapheme is read as its continuation; Slice 8's Ghostty cell flag should replace that rule. (this plan, Slice 6 run) +- 2026-10-24 `dump` and the grid golden (owner to weigh). `dump` puts the projection in the step's observation as a `projection` object, with `lines` empty; the projection is the graph's diagnostics as sorted-key JSON (per node: path, parent, children, depth, paint order, short `type` and `layout`, proposal, measured size, frame, clip, dirty flags, environment overrides, handler kinds, requirements, and the leaf's `text`), the statistics and invalidation reasons without durations, and the screen's rows and style runs, at `version` 1. `NAME.grid.txt` is the catalog's grid dialect over that screen: `── chars ──`, `── style ──` (a letter under each covered cell, `.` for the default, trailing default cells left off), and `── legend ──`; colours are `#rrggbb` or `indexed(N)`, and attributes are `bold`, `dim`, `italic`, `underline` (`underline=curly` and the other styles), `reverse`, and `strikethrough`. The virtual terminal exposes no wide flag, so a blank cell right after a width-2 grapheme is read as its continuation; Slice 9's Ghostty cell flag should replace that rule. (this plan, Slice 6 run) - 2026-10-24 Per-primitive demos absorb Slice 5's `stack` demo and are written to be read at 40 columns (owner to weigh). `stacks` replaces `stack` (and `stack-mobile.scn` with its goldens) and shows stacks, `Spacer`, and `layoutPriority`; the other five are `text` (the archived Unicode samples, kept short), `frame-padding`, `zstack`, `flex`, and `foreach`. Each takes a desktop and a mobile scenario that shots both, asserts one frame, and mostly runs `check`; a demo that overflows on purpose (`frame-padding`, `flex`) names the rules it passes, and a demo shows every variable style it has (alignments, padding, priorities, weights). (this plan, Slice 6 run) - 2026-10-24 Critic review of PR #67, decisions the findings settled (owner to weigh). `siblings-disjoint` looks through view lists (F1): the container that decides whether items overlap is the nearest ancestor that is not a `ForEach`, an `if` (`Optional` or `ConditionalView`), a tuple, or an empty view, so the layers a list or an `if` builds under a `ZStack` are layers and the same items under a stack are not. A list is recognised by its module-qualified type (`TesseraCore.ForEach`, `Swift.Optional`, and so on, decided in `TypeName` before the name is shortened and carried as the projection node's `isList`), so an app's own view named `ForEach` is an ordinary composite and is not looked through, and a new list type joins that set; the overlay and scrolling containers (`ZStack`, `ScrollView`) are still matched by their short name, so a view of an app's own named `ZStack` would be excused the same way, which only matters for a container-less body under it (owner to weigh). A body of several views with no container around them overlaps by the library's own placement, so `siblings-disjoint` reports it as a true positive, and the README says so. The grid golden has one row rule (F2), pinned before any golden has a style: the chars section ends at the last row with text or a style, with an empty line for a styled blank row, the style section ends at the last row with a style, and `NAME.txt` still ends at the last row with text, so the catalog dialect's "no more style rows than chars rows" holds by construction. A grid has 62 letters, and a screen with more distinct styles fails its `shot` as a harness error (exit 3, in `--record` too), because one letter for two styles would make the golden ambiguous (F3); a longer alphabet can be added later without drifting any golden that fits in 62. `run --pretty` prints a bare `check`'s warnings as `line N: warning RULE NODE: DETAIL` under the scenario's line and before its failures (F4). An empty path, name, or flag value (`--trace ""`, `--app ""`, `run ""`) is a usage error, exit 2 (F5), and so is an empty locator value, since `text=""` would match every leaf and the other keys nothing; `text=` is a substring match, and the README, the Developer tooling page, and the `tessera` skill say so (F11). A trace record's `version` is read before the rest of the line (F9), so a later schema is named by its version. A live trace records the terminal's focus changes as `focusGained` and `focusLost`, and replay delivers them (the review's open question): the library enables focus events by default and some terminals send a focus-in as soon as the mode is on, which would make every live trace unreplayable. The scenario grammar still has no statement for them, and mouse events and terminal replies stay `other` and are refused by name. The `flex` demo gained a second page (a `layoutPriority` tier, a child with no `.flex` value, cross-axis placement), the `stacks` demo a `Spacer(minLength:)` row and a `Spacer` in a `VStack`, and the `zstack` demo layers from a `ForEach` and an `if` (F6). (this plan, Slice 6 run) - 2026-10-24 The `tessera-devtools` skill is split in two (owner, Q13 option (a)). `.agents/skills/tessera/` is the published skill for app authors: the `tessera` CLI and `TesseraHarness` as an app author sees them, with one `| tessera | Does |` table, no repository paths, `mise`, demos, `DemoSupport`, or `@testable` API, and the rule that a Tessera bug is reported and not worked around. `.agents/skills/tessera-contributing/` is the in-repo contributor skill: it opens by saying everything in `tessera` applies, then adds a `| mise | tessera | Does |` table (`mise run dev:*` builds and runs the `tessera` executable), the demos and `DemoSupport`, adding a demo with its `withHeadlessHost` snapshot test, and the rule that the library is fixed with a regression test and a demo is never bent. Both skills describe only what exists, written as the finished current skill, with no future work in them. The published skill sits beside the repository's other skills so agents working here find it, and it links to nothing outside its own directory, so publishing it later is a copy of that directory; there is no symlink, because Windows checkouts handle them badly. `tessera-contributing` is named for who it serves (the repository has a `CONTRIBUTING.md`), where `tessera-devtools` named the tools and would now sit confusingly beside `tessera`. How the `tessera` skill is distributed is settled with Decision 9 (iii). This supersedes the skill sentences of the 2026-10-22 entry on the tools' design. (owner) - 2026-10-24 Help and error text is self-contained (owner). `tessera help`, every usage and parse error, and the public doc comments of `TesseraHarness` hold no repository path, plan language, or promise of later work, because the CLI is part of the 1.0 surface that app authors use and they do not have this repository. `tessera check` and `tessera replay` are not commands in Slice 5, so they are an unknown command (exit 2, naming the commands that exist), and their `mise` tasks are gone; Slice 6 adds them. A reserved scenario form reads "`wait` is not a statement a scenario can use; accepted: ..." and no longer names a README or what the form waits for, and `help run` lists the statements, so no pointer is needed. The README's "Not yet expressible" table stays as the design's list, held to `ScenarioGrammar.notYetExpressible` by the parity test. This supersedes the Slice 5 refusal entry for the commands and the refusal text. (this plan, PR #66 follow-up) - 2026-10-24 The `tessera` CLI adopts swift-argument-parser, and the `dev:*` tasks complete and explain themselves (owner, review of PR #65). `HarnessCLI` declares its commands with `apple/swift-argument-parser` (`from: "1.8.2"`, the newest release, which fixes fish completion scripts) now, before the CLI surface freezes at 1.0. Only `TesseraHarness` depends on it, and `scripts/check-package-boundaries.py` allowlists that one product; `Tessera`, core, layout, widgets, and the terminal targets do not. The stable surface is unchanged: exit codes 0 to 3 (`HarnessCLI` maps ArgumentParser's usage exit 64 to 2), one JSON object when stdout is not a terminal or under `--json` and the text form under `--pretty`, `{"command","exitCode","message"}` for a failure, the command and flag names, and `help [COMMAND]`; help text and parse-error wording become ArgumentParser's. The executable completes app names itself through ArgumentParser's custom completion, with `--generate-completion-script SHELL` for fish, zsh, and bash, so `tessera launch ` works under each. A `dev:*` task run with no app name prints the command's help and the registered apps instead of mise's "Missing required arg" error, and exits 0 at a terminal and 2 in a pipe or a script, as `tessera` does, and `mise run dev:launch ` completes the names, through the usage spec's `arg "[name]"` and `complete "name" run=...`, from the already-built `Examples/.build/debug/tessera`, printing nothing rather than building when there is none. (owner) -- Open, Decision 9: how app authors consume the harness. (i) Decided 2026-10-23; see the entries above. (ii) Distribution of libghostty-vt: decided 2026-10-22 (owner) after [libghostty-vt distribution](../research/libghostty-vt-distribution.md): an SE-0482 static-library artifact bundle built by Tessera's CI once per Ghostty pin and published as a release asset behind `binaryTarget(url:checksum:)`, with the pinned headers committed in `CGhosttyVT`. Taken and implemented 2026-10-22 by [Ship libghostty-vt as a prebuilt binary](libghostty-vt-binary-distribution.md): `Package.swift` links the `ghostty-vt-f54340ad7781` release, no test job builds Zig, and [libghostty-vt distribution](../architecture/libghostty-vt-distribution.md) records the rule. (iii) How an app gets `tessera`: a per-app executable calling `HarnessCLI.main(registry:)`, a prebuilt front end (GitHub releases, Homebrew, mise) that builds and forwards to that executable, or a SwiftPM command plugin. Deferred by the owner (2026-10-22); a per-app executable is needed under every option, so Slices 4-18 build that and nothing forecloses the others; settle before Slice 19. The owner leans toward the SwiftPM command plugin (2026-10-24); still to be settled before Slice 19. +- 2026-10-25 Q14, the first demos' library problems get their own slice: the Slice 6 demos exposed them (the dirty `@ViewBuilder` root, `layoutEnvironmentChanged` on every step, the `Spacer` with `layoutPriority(1)`, `Flex`'s inverted fill weights and missing alignment; see Discoveries), so a new Slice 7, Library fixes from the first demos, follows Slice 6 and precedes Flow and `ViewThatFits`. Slices 7-20 become 8-21: Flow and `ViewThatFits` 7 to 8, styling 8 to 9, focus 9 to 10, mouse 10 to 11, ScrollView 11 to 12, roles and labels 12 to 13, controls 13 to 14, collections 14 to 15, SplitView 15 to 16, the animation path 16 to 17, remaining views 17 to 18, live inspection 18 to 19, docs and the conformance gate 19 to 20, the intro app 20 to 21. Entries above use the new numbers, as at the 2026-10-23 renumber. (owner) +- 2026-10-25 Q15, layout priority reserves the lower tiers' minimums: a higher `layoutPriority` tier receives the remaining space minus the minimums of all lower-priority children (SwiftUI's rule), instead of the whole remainder, so a high-priority `Spacer` cannot push lower tiers past the edge. This amends step 3 of `docs/spec/phase-4/slice-02-layout-protocol-and-stacks.md` and its priority tests; Slice 7 makes the amendment. (owner) +- 2026-10-25 Q16, `Flex` gains cross-axis alignment: `alignment: FlexAlignment = .start` with cases `.start`, `.center`, and `.end`, placing a child smaller than the cross extent relative to the axis, like CSS `align-items`. There is no `stretch` and no main-axis justification for now. The default keeps today's frames. `docs/design/primitives/flex.md` and the Flex spec are updated in Slice 7. (owner) +- Open, Decision 9: how app authors consume the harness. (i) Decided 2026-10-23; see the entries above. (ii) Distribution of libghostty-vt: decided 2026-10-22 (owner) after [libghostty-vt distribution](../research/libghostty-vt-distribution.md): an SE-0482 static-library artifact bundle built by Tessera's CI once per Ghostty pin and published as a release asset behind `binaryTarget(url:checksum:)`, with the pinned headers committed in `CGhosttyVT`. Taken and implemented 2026-10-22 by [Ship libghostty-vt as a prebuilt binary](libghostty-vt-binary-distribution.md): `Package.swift` links the `ghostty-vt-f54340ad7781` release, no test job builds Zig, and [libghostty-vt distribution](../architecture/libghostty-vt-distribution.md) records the rule. (iii) How an app gets `tessera`: a per-app executable calling `HarnessCLI.main(registry:)`, a prebuilt front end (GitHub releases, Homebrew, mise) that builds and forwards to that executable, or a SwiftPM command plugin. Deferred by the owner (2026-10-22); a per-app executable is needed under every option, so Slices 4-19 build that and nothing forecloses the others; settle before Slice 20. The owner leans toward the SwiftPM command plugin (2026-10-24); still to be settled before Slice 20. ## 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 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. +- A `SplitView` pane whose content is a nested multi-view list now places only its first view (plan 034 Step 10.1 discovery) — Slice 16 settles the rule. +- `Text` does not wrap at HEAD and ignores its width proposal (plan 033) — Slice 9. +- `handlerKinds` and the focus and requirement counters are dead at HEAD (plan 033; `focusChanges` confirmed in plan 034 R-10) — Slice 10. - 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 `@MainActor` host could not present through `TerminalSession.draw`: its closure and result were `sending`, so a main-actor caller could not send its graph in (decision 9 (i) record, result 4). The runtime isolation decision relaxes `draw` to `nonisolated(nonsending)` — Slice 2. The Phase 5 sketch of `TestRuntime(scene:)` did not compile either (`borrowing var body`, an undefined `EventRouting`) and is gone from the spec. - 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. @@ -320,7 +330,7 @@ Rules for an agent running slices of this plan without the owner. - 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. +- `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 20'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. @@ -339,14 +349,14 @@ Rules for an agent running slices of this plan without the owner. - Every step of a demo built on stacks reports `layoutEnvironmentChanged` and `renderEnvironmentChanged`: stacks rebuild their children's `EnvironmentValues` on each update, and reconciliation compares environment storage by identity (`===`), so an environment that did not change reads as changed. The trace names it on every redraw of `stacks`, `flex`, and `foreach`, so no scenario can bound the reasons yet; the library should compare values or keep one storage across updates (owner to weigh). - The headless virtual terminal lacks DEC mode 2027, so a ZWJ emoji, a regional-indicator pair, and a variation-selector-16 sequence advance a different number of cells than Tessera's width table says (`👩🏽‍💻` takes 6 cells in the terminal and measures 2), and Tessera neither enables mode 2027 nor repositions the cursor after a wide grapheme. The `text` demo keeps its Unicode lines short and puts the samples where a drift cannot shift a neighbour, and its goldens show the terminal's cells (owner to weigh). - A `Spacer` with `layoutPriority(1)` in `HStack { L; Spacer.layoutPriority(1); M; Spacer; R }` takes all the remaining width and places `M` and `R` past the edge, because a higher tier allocates first (as the spec says) and a `Spacer`'s ideal is unbounded; the `stacks` demo also gives priority to its neighbours so every row fits (owner to weigh). -- `Flex`'s `min(n)` floor cannot compress a `Text` child, since `Text`'s minimum equals its ideal while it does not wrap (Slice 8), and when the children are larger than the space the fill weights invert (owner to weigh); the `flex` demo asserts today's frames. -- `measured-within-proposal` and `frame-inside-clip` fire by contract on a fixed frame wider than its parent, on a Flex child that overflows, and on `Text` that does not wrap, which is why `frame-padding` and `flex` name the rules they pass. The `workspace` demo at `mobileScreen` (40x16) fails `measured-within-proposal` on a collapsed pane's `Text`: `tessera check workspace mobileScreen` exits 1 with `measured 7 columns where 0 were offered` at `root/index(2)/id(catalog)/index(0)/index(0)` until Slice 8 wraps `Text`. +- `Flex`'s `min(n)` floor cannot compress a `Text` child, since `Text`'s minimum equals its ideal while it does not wrap (Slice 9), and when the children are larger than the space the fill weights invert (owner to weigh); the `flex` demo asserts today's frames. +- `measured-within-proposal` and `frame-inside-clip` fire by contract on a fixed frame wider than its parent, on a Flex child that overflows, and on `Text` that does not wrap, which is why `frame-padding` and `flex` name the rules they pass. The `workspace` demo at `mobileScreen` (40x16) fails `measured-within-proposal` on a collapsed pane's `Text`: `tessera check workspace mobileScreen` exits 1 with `measured 7 columns where 0 were offered` at `root/index(2)/id(catalog)/index(0)/index(0)` until Slice 9 wraps `Text`. - As Slice 4 found, a graph rooted at an `@ViewBuilder` body is dirty after every `update()`, so every step of every demo draws (`drew: true`); the cost counters show it, and a step that changes nothing still writes bytes. `no-dirty-after-pass` passes on the corpus because a completed pass clears every flag, and `ChecksTests` crafts a projection that still has a dirty node to prove it fails. The fixtures in `ProjectionFixtures` with an explicit root skip draws (`drew: false`), which is how a clean step's trace record and cost are tested. -- No view in the corpus sets a terminal requirement, so `requirements-aggregate` passes vacuously there; its tests use crafted projections, and Slice 10's pointer demo (any-event tracking requested only while a handler needs it) is its first live subject. +- No view in the corpus sets a terminal requirement, so `requirements-aggregate` passes vacuously there; its tests use crafted projections, and Slice 11's pointer demo (any-event tracking requested only while a handler needs it) is its first live subject. - Tooling: `mise run quality:format` and `quality:lint` skip `Examples/Tests`, but the pre-commit hook lints every staged Swift file, so format that directory by hand (`scripts/swift-format.sh format --configuration .swift-format -i` and `swiftlint lint --fix`) before committing. A `--scratch-path .build-27` directory is untracked and not ignored, so `git add -A` stages SwiftPM's checkouts as embedded repositories and the hook's stash of untracked files timed out on it; delete the directory before staging. `swift run --package-path Examples` without `--only-use-versions-from-resolved-file` rewrites `Examples/Package.resolved` (the mise tasks pass the flag). - The layers of a `ForEach` or an `if` under a `ZStack` hang off a list node in the tree, so a rule that reads a node's parent sees `ForEach` where layout sees the `ZStack`: the first `siblings-disjoint` tested only the immediate parent and failed on `ZStack { ForEach … }` and on `ZStack { if … { A; B } }` (critic review of PR #67, F1). A rule that reasons about layout must look through the list nodes (`ForEach`, `Optional`, `ConditionalView`, `TupleView`, `EmptyView`) the way `makeLayoutSubviews` does; `frame-inside-clip` and `measured-within-proposal` read one node and are not affected. - `Flex` takes no alignment: a child smaller than the cross extent always sits at the leading edge (`VerticalAlignment.top` for a horizontal `Flex`, `HorizontalAlignment.leading` for a vertical one, in `Flex.swift`), where `flex.md` says placement uses "the selected" alignment and the public initializer has no such parameter. The `flex` demo's second page shows what exists, and a scenario pins it (owner to weigh). -- A desktop viewport is 24 rows, so a demo that needs more pages: the `flex` demo's constraints and its priority, default, and cross-axis samples are two pages (`n`), and the `stacks` demo needs 22 rows, 24 with `v` pressed. The `text` demo's wrapped, truncated, and mode-truncated fixtures are not in it because `Text` does not wrap or truncate yet (Slice 8), and `text.md` now says so. +- A desktop viewport is 24 rows, so a demo that needs more pages: the `flex` demo's constraints and its priority, default, and cross-axis samples are two pages (`n`), and the `stacks` demo needs 22 rows, 24 with `v` pressed. The `text` demo's wrapped, truncated, and mode-truncated fixtures are not in it because `Text` does not wrap or truncate yet (Slice 9), and `text.md` now says so. ## Outcome From 45160b349ac7e60c2888b17d5c9072ce81d00a51 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sun, 25 Oct 2026 20:19:51 -0400 Subject: [PATCH 53/55] docs(plans): refresh plan 036's next step after #59-#66 merged --- 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 e331bf8..8a139b3 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -36,7 +36,7 @@ This plan merges plan 032 (Phases 8-17, its open implementation phases; Phases 1 - [ ] 20 Docs, skill, and the conformance gate - [ ] 21 Intro app and Phase 4 closure -Next: Slice 1 in its own pull request (PR #60 is open). Slices 2, 3, 4, 5, and 6 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`; PR #66, stacked on #65: the scenario language, the runner, `tessera run`, `shot`, and `tree`, and the CI corpus; PR #67, stacked on #66: the projection, locators, rules, `expect cost`, the trace, grid goldens, and the first demos). Slice 7 (the library fixes from the first demos) follows #67 and can branch from `phase-4/06-projection` until #59, #64, #65, #66, and #67 merge; Slice 8 (Flow and `ViewThatFits`) follows it and needs the design catalog's PR #62 merged first. The owner approved the Slice 3 decisions that were marked "(owner to weigh)" on 2026-10-24; the Slice 4-6 decisions marked "(owner to weigh)" are reversible and do not block them. Decision 9 (i) is recorded below and the owner decided Q14-Q16 on 2026-10-25, so nothing in Slices 7 and 8 waits on the owner. +Next: Slice 1 in its own pull request (PR #60 is open). Slices 2 through 5 are merged (PRs #59, #64, #65, #66); Slice 6 is PR #67 (the projection, locators, rules, `expect cost`, the trace, grid goldens, and the first demos). Slice 7 (the library fixes from the first demos) follows #67 and can branch from `phase-4/06-projection` until #67 merges; Slice 8 (Flow and `ViewThatFits`) follows it, and the design catalog's PR #62 it needs is merged. The owner approved the Slice 3 decisions that were marked "(owner to weigh)" on 2026-10-24; the Slice 4-6 decisions marked "(owner to weigh)" are reversible and do not block them. Decision 9 (i) is recorded below and the owner decided Q14-Q16 on 2026-10-25, so nothing in Slices 7 and 8 waits on the owner. ## Approach From a0783de4a950a0a5068b24fff0462c4c04e0b971 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sun, 25 Oct 2026 20:36:14 -0400 Subject: [PATCH 54/55] docs(plans): close the short-name container match in Slice 7 --- docs/plans/phase-4-view-layer-and-developer-tooling.md | 7 +++++-- 1 file changed, 5 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 8a139b3..e9a2e50 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -121,9 +121,11 @@ Delivers: each landed primitive has snapshot coverage at both sizes and reads we ### 7 Library fixes from the first demos -Owner decisions Q14-Q16 (2026-10-25). The Slice 6 demos exposed library defects, recorded in Discoveries: a graph rooted at an `@ViewBuilder` body is dirty after every `update()`, every step of `stacks`, `flex`, and `foreach` reports `layoutEnvironmentChanged`, a `Spacer` with `layoutPriority(1)` takes the whole remainder, `Flex` fill weights invert when its children are larger than the space, and `Flex` takes no alignment. The demos work around or pin them today, so they are fixed here, right after Slice 6 and before Flow and `ViewThatFits` build on the same layout code; the harness still never compensates for a library bug. Five fixes in one reviewable step. (i) A graph rooted at an `@ViewBuilder` body is clean after an `update()` that changes nothing: the builder wraps even one view in a `TupleView`, which is not equatable, so the root is re-evaluated and marked every time, and the slice makes reconciliation skip an unchanged root. (ii) Reconciliation stops reporting `layoutEnvironmentChanged` and `renderEnvironmentChanged` for an unchanged environment: stacks rebuild their children's `EnvironmentValues` on each update and reconciliation compares storage by identity (`===`); the slice chooses between comparing by value and keeping one storage across updates, and records the choice under Decisions. (iii) Q15: a higher `layoutPriority` tier receives the remaining space minus the minimums of all lower-priority children (SwiftUI's rule), not the whole remainder, so `HStack { L; Spacer().layoutPriority(1); M; Spacer(); R }` keeps `M` and `R` on screen. The slice amends step 3 of `docs/spec/phase-4/slice-02-layout-protocol-and-stacks.md` and its priority-tier distribution tests in the same change; the amended wording says how rigid children, the reserved minimums, and the integer remainder rule interact, and the slice notes in Discoveries whether Flex's own tier rule needs the same reservation. (iv) `Flex` fill weights stop inverting when the children are larger than the space: the slice settles the rule against the negative-remainder text of the Flex spec (`docs/spec/phase-4/slice-02-5-flex-and-splitview-negotiation.md` and `docs/design/primitives/flex.md`: `fill` allocations are removed by weight toward zero, equal-tier remainder cells from later children first) and amends whichever of the code and the text is wrong. (v) Q16: `Flex` gains `alignment: FlexAlignment = .start` with cases `.start`, `.center`, and `.end`, the cross-axis placement of a child smaller than the cross extent, relative to the axis, like CSS `align-items`; there is no `stretch` and no main-axis justification for now. The default keeps today's frames (`.start` is the leading edge: `VerticalAlignment.top` for a horizontal `Flex`, `HorizontalAlignment.leading` for a vertical one), and `docs/design/primitives/flex.md` (its cross-axis placement sentence and public initializer) and the Flex spec are updated in the same change. `Flex`'s `min(n)` floor on a non-wrapping `Text` child stays with Text wrapping (Slice 9). +Owner decisions Q14-Q16 (2026-10-25). The Slice 6 demos exposed library defects, recorded in Discoveries: a graph rooted at an `@ViewBuilder` body is dirty after every `update()`, every step of `stacks`, `flex`, and `foreach` reports `layoutEnvironmentChanged`, a `Spacer` with `layoutPriority(1)` takes the whole remainder, `Flex` fill weights invert when its children are larger than the space, and `Flex` takes no alignment. The demos work around or pin them today, so they are fixed here, right after Slice 6 and before Flow and `ViewThatFits` build on the same layout code; the harness still never compensates for a library bug. Six fixes in one reviewable step, the sixth in the harness's own rules. (i) A graph rooted at an `@ViewBuilder` body is clean after an `update()` that changes nothing: the builder wraps even one view in a `TupleView`, which is not equatable, so the root is re-evaluated and marked every time, and the slice makes reconciliation skip an unchanged root. (ii) Reconciliation stops reporting `layoutEnvironmentChanged` and `renderEnvironmentChanged` for an unchanged environment: stacks rebuild their children's `EnvironmentValues` on each update and reconciliation compares storage by identity (`===`); the slice chooses between comparing by value and keeping one storage across updates, and records the choice under Decisions. (iii) Q15: a higher `layoutPriority` tier receives the remaining space minus the minimums of all lower-priority children (SwiftUI's rule), not the whole remainder, so `HStack { L; Spacer().layoutPriority(1); M; Spacer(); R }` keeps `M` and `R` on screen. The slice amends step 3 of `docs/spec/phase-4/slice-02-layout-protocol-and-stacks.md` and its priority-tier distribution tests in the same change; the amended wording says how rigid children, the reserved minimums, and the integer remainder rule interact, and the slice notes in Discoveries whether Flex's own tier rule needs the same reservation. (iv) `Flex` fill weights stop inverting when the children are larger than the space: the slice settles the rule against the negative-remainder text of the Flex spec (`docs/spec/phase-4/slice-02-5-flex-and-splitview-negotiation.md` and `docs/design/primitives/flex.md`: `fill` allocations are removed by weight toward zero, equal-tier remainder cells from later children first) and amends whichever of the code and the text is wrong. (v) Q16: `Flex` gains `alignment: FlexAlignment = .start` with cases `.start`, `.center`, and `.end`, the cross-axis placement of a child smaller than the cross extent, relative to the axis, like CSS `align-items`; there is no `stretch` and no main-axis justification for now. The default keeps today's frames (`.start` is the leading edge: `VerticalAlignment.top` for a horizontal `Flex`, `HorizontalAlignment.leading` for a vertical one), and `docs/design/primitives/flex.md` (its cross-axis placement sentence and public initializer) and the Flex spec are updated in the same change. `Flex`'s `min(n)` floor on a non-wrapping `Text` child stays with Text wrapping (Slice 9). -Delivers: a graph is clean after an idle step, an unchanged environment is not reported as changed, a high-priority `Spacer` no longer pushes lower tiers past the edge, oversized `Flex` fill weights keep their order, and `Flex` aligns children on its cross axis. Verify: an idle step of the `stacks`, `flex`, and `foreach` demos reports no environment reasons and draws nothing (`drew: false`, no bytes); the `stacks` demo drops the neighbour priorities it uses as a workaround; the `flex` demo shows `.center` and `.end`, and its scenario pins the frames; distribution-table tests cover the reserved minimums and oversized fill weights; goldens change only where these fixes move frames. +(vi) Owner decision 2026-10-25: projection nodes gain a `module` field, and `siblings-disjoint` and `frame-inside-clip` match their overlapping and scrolling containers (`ZStack`, `ScrollView` in `Checks.swift`) by module and type, as view lists already are through `isList`, so an app's own `ZStack` or `ScrollView` is held to the rules instead of exempted. + +Delivers: a graph is clean after an idle step, an unchanged environment is not reported as changed, a high-priority `Spacer` no longer pushes lower tiers past the edge, oversized `Flex` fill weights keep their order, `Flex` aligns children on its cross axis, and the rules' container exemptions apply to Tessera's types only. Verify: an idle step of the `stacks`, `flex`, and `foreach` demos reports no environment reasons and draws nothing (`drew: false`, no bytes); the `stacks` demo drops the neighbour priorities it uses as a workaround; the `flex` demo shows `.center` and `.end`, and its scenario pins the frames; distribution-table tests cover the reserved minimums and oversized fill weights; a `ChecksTests` fixture with an app type named `ZStack` whose children overlap fails `siblings-disjoint`; goldens change only where these fixes move frames. ### 8 Flow layout and `ViewThatFits` @@ -304,6 +306,7 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-25 Q14, the first demos' library problems get their own slice: the Slice 6 demos exposed them (the dirty `@ViewBuilder` root, `layoutEnvironmentChanged` on every step, the `Spacer` with `layoutPriority(1)`, `Flex`'s inverted fill weights and missing alignment; see Discoveries), so a new Slice 7, Library fixes from the first demos, follows Slice 6 and precedes Flow and `ViewThatFits`. Slices 7-20 become 8-21: Flow and `ViewThatFits` 7 to 8, styling 8 to 9, focus 9 to 10, mouse 10 to 11, ScrollView 11 to 12, roles and labels 12 to 13, controls 13 to 14, collections 14 to 15, SplitView 15 to 16, the animation path 16 to 17, remaining views 17 to 18, live inspection 18 to 19, docs and the conformance gate 19 to 20, the intro app 20 to 21. Entries above use the new numbers, as at the 2026-10-23 renumber. (owner) - 2026-10-25 Q15, layout priority reserves the lower tiers' minimums: a higher `layoutPriority` tier receives the remaining space minus the minimums of all lower-priority children (SwiftUI's rule), instead of the whole remainder, so a high-priority `Spacer` cannot push lower tiers past the edge. This amends step 3 of `docs/spec/phase-4/slice-02-layout-protocol-and-stacks.md` and its priority tests; Slice 7 makes the amendment. (owner) - 2026-10-25 Q16, `Flex` gains cross-axis alignment: `alignment: FlexAlignment = .start` with cases `.start`, `.center`, and `.end`, placing a child smaller than the cross extent relative to the axis, like CSS `align-items`. There is no `stretch` and no main-axis justification for now. The default keeps today's frames. `docs/design/primitives/flex.md` and the Flex spec are updated in Slice 7. (owner) +- 2026-10-25 The rules' container exemptions match by module: projection nodes gain a `module` field, and `siblings-disjoint` and `frame-inside-clip` exempt only Tessera's own `ZStack` and `ScrollView`, as view lists are already matched by module-qualified name, so an app type with the same short name is held to the rules. Closed in Slice 7 rather than left as a known gap. (owner) - Open, Decision 9: how app authors consume the harness. (i) Decided 2026-10-23; see the entries above. (ii) Distribution of libghostty-vt: decided 2026-10-22 (owner) after [libghostty-vt distribution](../research/libghostty-vt-distribution.md): an SE-0482 static-library artifact bundle built by Tessera's CI once per Ghostty pin and published as a release asset behind `binaryTarget(url:checksum:)`, with the pinned headers committed in `CGhosttyVT`. Taken and implemented 2026-10-22 by [Ship libghostty-vt as a prebuilt binary](libghostty-vt-binary-distribution.md): `Package.swift` links the `ghostty-vt-f54340ad7781` release, no test job builds Zig, and [libghostty-vt distribution](../architecture/libghostty-vt-distribution.md) records the rule. (iii) How an app gets `tessera`: a per-app executable calling `HarnessCLI.main(registry:)`, a prebuilt front end (GitHub releases, Homebrew, mise) that builds and forwards to that executable, or a SwiftPM command plugin. Deferred by the owner (2026-10-22); a per-app executable is needed under every option, so Slices 4-19 build that and nothing forecloses the others; settle before Slice 20. The owner leans toward the SwiftPM command plugin (2026-10-24); still to be settled before Slice 20. ## Discoveries From 28d67fa2ddabe5d98c5708b4a0983f0f2d258ae4 Mon Sep 17 00:00:00 2001 From: Rob Feldmann Date: Sun, 25 Oct 2026 20:44:45 -0400 Subject: [PATCH 55/55] docs(plans): record the grid golden's style-key ranges decision --- docs/plans/phase-4-view-layer-and-developer-tooling.md | 1 + 1 file changed, 1 insertion(+) 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 e9a2e50..61ada15 100644 --- a/docs/plans/phase-4-view-layer-and-developer-tooling.md +++ b/docs/plans/phase-4-view-layer-and-developer-tooling.md @@ -307,6 +307,7 @@ Rules for an agent running slices of this plan without the owner. - 2026-10-25 Q15, layout priority reserves the lower tiers' minimums: a higher `layoutPriority` tier receives the remaining space minus the minimums of all lower-priority children (SwiftUI's rule), instead of the whole remainder, so a high-priority `Spacer` cannot push lower tiers past the edge. This amends step 3 of `docs/spec/phase-4/slice-02-layout-protocol-and-stacks.md` and its priority tests; Slice 7 makes the amendment. (owner) - 2026-10-25 Q16, `Flex` gains cross-axis alignment: `alignment: FlexAlignment = .start` with cases `.start`, `.center`, and `.end`, placing a child smaller than the cross extent relative to the axis, like CSS `align-items`. There is no `stretch` and no main-axis justification for now. The default keeps today's frames. `docs/design/primitives/flex.md` and the Flex spec are updated in Slice 7. (owner) - 2026-10-25 The rules' container exemptions match by module: projection nodes gain a `module` field, and `siblings-disjoint` and `frame-inside-clip` exempt only Tessera's own `ZStack` and `ScrollView`, as view lists are already matched by module-qualified name, so an app type with the same short name is held to the rules. Closed in Slice 7 rather than left as a known gap. (owner) +- 2026-10-25 Q18, the grid golden's style keys are not capped at 62: after `0-9`, `A-Z`, and `a-z`, keys continue through a short published list of Unicode ranges of single-width letters that do not look like ASCII (Latin Extended-A, U+0100-U+017F, then further ranges as needed), assigned in order of first appearance; the dialect states the ranges, not a character list, and still puts one key under each cell. Slice 9 makes the change and amends the dialect in `docs/design.md` and the harness's grid documentation, since styling is where screens first need more than 62 styles. (owner) - Open, Decision 9: how app authors consume the harness. (i) Decided 2026-10-23; see the entries above. (ii) Distribution of libghostty-vt: decided 2026-10-22 (owner) after [libghostty-vt distribution](../research/libghostty-vt-distribution.md): an SE-0482 static-library artifact bundle built by Tessera's CI once per Ghostty pin and published as a release asset behind `binaryTarget(url:checksum:)`, with the pinned headers committed in `CGhosttyVT`. Taken and implemented 2026-10-22 by [Ship libghostty-vt as a prebuilt binary](libghostty-vt-binary-distribution.md): `Package.swift` links the `ghostty-vt-f54340ad7781` release, no test job builds Zig, and [libghostty-vt distribution](../architecture/libghostty-vt-distribution.md) records the rule. (iii) How an app gets `tessera`: a per-app executable calling `HarnessCLI.main(registry:)`, a prebuilt front end (GitHub releases, Homebrew, mise) that builds and forwards to that executable, or a SwiftPM command plugin. Deferred by the owner (2026-10-22); a per-app executable is needed under every option, so Slices 4-19 build that and nothing forecloses the others; settle before Slice 20. The owner leans toward the SwiftPM command plugin (2026-10-24); still to be settled before Slice 20. ## Discoveries