Help us shape the future of Go! Take the 2026 Go Developer Survey

monogatari

package module
v0.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 31, 2026 License: MIT Imports: 27 Imported by: 0

README

monogatari
Latest release Go Reference Go version License

Stories for apps built on ochazuke: JSON keystroke scripts played headless against your app's real UI, with expectations on what the screen shows.

A story is a JSON file that names an app, a fixture and a script of keys, and says what the screen must show afterwards. monogatari plays it against the app's real ochazuke.Model, headless under go test or live in a terminal, so a UI bug becomes a story before it is fixed and a reviewer can walk an app change key by key without touching anyone's data.

Module github.com/unixsuperhero/monogatari; requires Go 1.26 or newer. It requires ochazuke and oden; neither requires it.

Status: v0.1.0, early. The engine is here: the story format, the runner and driver, recordings, trimming and the explorer (moved from ochazuke's story and explore packages, which are now frozen and deprecated), with the app seam below, and the host (monogatari/host: CLI, catalog, index TUI, player, explore's watch mode; moved from ochazuke's o-story). ochazuke's own o-story runs on it; the app testers (mtm, o-json) are next.

What it does

  • Stories. JSON keystroke scripts with expectations: the focused list, the mode, text the screen contains or lacks, the highlighted rows, the page. Keys are named as bindings name them (ctrl+n, esc, shift+tab).
  • Headless runs. Every story runs under go test against the app's real UI, with a fake clock, jobs and ticks, so a run is the same every time.
  • Recordings. Every live session is recorded: a ring of the last 5 per story, plus named saves you keep. replay plays one back with its timing; trim cuts it down to the keys that matter; run --from turns one into a headless check (Store, Recorder, Library.Trim, Library.Replay; the commands come with the host).
  • Explorer. Walks an app's states from a story's start and reports what breaks (panics, focus traps, hints that lie, renders that overflow, jobs that never settle), each finding written out as a story that reproduces it (monogatari/explore; Library.Subject explores a story's app).
  • A host per app. CLI subcommands, an index TUI and a player, as an oden command an app mounts under its own name (mtm story ...), each host with its own user stories and recordings (see "The host" below).

monogatari knows no app. An app's behaviour comes in through its fixtures (named starting states that build its runtime from a seed) as data; the library never branches on an app's name.

A story

A story from ochazuke's own suite (page is a field of ochazuke's demo app, which builds a synthetic page from it). app, fixture and seed name which app to build, from which starting state and with what data; a story with no app runs on the host's first app, with no fixture on the app's first.

{
  "name": "the palette highlights nothing until ctrl+n",
  "area": "palette",
  "page": { "lists": [{ "id": "a", "rows": 3 }] },
  "script": [":", "ctrl+n"],
  "expect": [
    { "after": 1, "highlighted": 0, "dimmed": 1, "mode": "insert", "lacks": ["> "] },
    { "highlighted": 0, "dimmed": 1, "contains": ["> accept"] }
  ]
}

An expect entry checks the screen after a step (after) or, without one, at the end.

The app seam

A host registers its apps in a Library; there is no built-in app. Each app has named fixtures, and a fixture builds the app's runtime from what Env gives it and nothing else on the machine:

lib := monogatari.Library{Stories: stories, Apps: []monogatari.App{{
	Name:   "mtm",
	Fields: nil, // top-level story fields this app reads (the demo's: page, pages)
	Fixtures: []monogatari.Fixture{{Name: "seeded", Build: func(env monogatari.Env) (monogatari.Built, error) {
		a := newFakedApp(env.Seed, env.StateDir, env.Now) // temp database, dead socket, fake panes
		return monogatari.Built{Model: a.Model(), Program: a, Close: a.Close}, nil
	}}},
	Expected: func(op, err string) bool { return err == "daemon unavailable" },
}}}
res := lib.Run(story, monogatari.RunOptions{Golden: true})
  • The host does the generic parts for every app: temp state, config and file dirs; the story's settings, values and session (Env.Sandbox, or Env.NewRuntime for a fixture that builds from scratch); the config files loaded after the build; checks; theme, size and chrome.
  • Built.Program is the app's own tea.Model when it wraps the ochazuke model's Update and View: the driver sends every event to it and the frame is its view. Built.Init runs its Init first; Built.Start runs after the first frame.
  • reset (Env.Reset from a verb) and a restart event rebuild through the same fixture; a restart keeps the state directory.
  • A story naming an app the host lacks fails with app "mtm" is not in this host (run it with mtm story); a top-level field its app does not declare in Fields fails too.

Each app builds its own tester

An app links monogatari only in its tester build, behind the build tag monogatari. Its story mount, its fixtures and its fakes live in files marked //go:build monogatari; a !monogatari file registers nothing, so a normal binary compiles and links none of it (go tool nm <binary> | grep -c monogatari is 0).

go build -tags monogatari -o bin/mtm-story ./cmd/mtm   # the tester, never installed over the app
go test -race -tags monogatari ./...                   # every test, stories included

The tester never touches the owner's data: every session gets a temp database and a socket nothing listens on. In each app:

mtm-story story run panes              # headless; non-zero exit on failure
mtm-story story open panes/detach-keeps-preview   # live, in the real chrome
mtm-story story history                # the ring: 1 = newest, and the saved names
mtm-story story keep 2 detach-bug      # promote ring entry 2 to a named save
mtm-story story replay detach-bug      # live, with the recorded timing
mtm-story story explore panes          # the state explorer

The rules for the tag (tagged files, the !monogatari stub, the gate that runs both builds, the symbol check) are in AGENTS.md.

The host

Tutorial stories use a reset point and independent actions instead of a script:

{
  "name": "Learn the row motions",
  "fixture": "plain",
  "setup": ["j"],
  "expect": {"focus": "a2"},
  "actions": [
    {"label": "Move down", "caption": "j moves down one row.", "keys": ["j"], "expect": {"focus": "a3"}},
    {"label": "Move up", "caption": "k moves up from the reset point.", "keys": ["k"], "expect": {"focus": "a1"}}
  ]
}

setup accepts the same key strings and events as script. The player shows setup once from the opening frame, then rebuilds the fixture and replays setup before each action. Edits, registers, config and state files from an action never leak into the next. story run does the same resets headlessly. The top-level expect checks setup (with after relative to setup); each action's optional expect checks its keys (after relative to that action). Tutorials need no golden. Use script/base for existing sequential stories; combining them with actions is an error. An optional action id keeps its inventory identity while label uses words for people.

story open NAME opens any story in the player with autoplay running (400 ms per event by default). space or a pauses/resumes, +/- change the speed, and l or right steps and pauses autoplay. play.action-pause defaults to 3s before and after each action, giving time to read its caption and see its result; 0s uses the key playback speed. play.stop-after-action defaults to false. Turn it on in Settings or with :set play.stop-after-action=true to pause at the next action's reset point after an action finishes; space continues. Setup still flows into the first action, and the last action ends on its result. Ordinary stories are unaffected. The host CLI exposes the same player settings. The story host/testdata/browser-stories/tutorials/stop-after-action.json checks the pause and continuation. [ and ] jump to the previous/next action's reset point, paused; r replays from the opening frame. Tutorials show one tree: setup steps end at a reset row, followed by action titles with captions as detail and key steps as children. Before every action the cursor visibly returns to the reset row, then walks its key children. Long actions keep their parent and caption in view while steps scroll. play.tree defaults to true; false restores the active-segment column. The host setting play.captions defaults to true; :set play.captions false hides captions and notes.

The player shows a state strip below the preview (play.inspector, default true). Changed values appear first with an accent and Δ: register text, messages with their tone, reported writes, app fields, and mode/focus or selection changes. Values wrap into at most three rows; narrow views clip the preview to leave room for the strip. alt+? toggles it in the index and player; :set play.inspector=false hides it. Cached first-frame previews retain their opening state after the fixture session closes. The toggle is a row in host/bindings.json using the existing setting.edit verb. The strip reads ochazuke's Runtime.Inspect() and the app seam's existing write log. It stays outside the app frame: expect, goldens and story run output are unchanged. See host/testdata/stories/tutorials/copy-path.json and its player frame in host/testdata/inspector.golden.

Compared with Vim: this combines the register visibility of :registers (:help :registers) and the diagnostic purpose of :messages/:verbose (:help :messages, :help :verbose). It retains the latest message and shows state changes for one step. It does not start a :redir session (:help :redir); structured export belongs to the inspected runtime's state inspect verb.

All stories support native notes: [{"after": 1, "note": "What changed"}]; actions can also have their own notes. An event can carry note beside its key, type, paste or job, shown before that event plays. Apps do not need to declare a notes field. Older {after, keys, note} notes keep their key legend when round-tripped. See the independent motions example.

Compared with Vim: tutorial actions and reset replay have no Vim counterpart; they are player controls, and never enter the application's key stream. Stopping after an action likewise has no Vim counterpart: it waits at the next lesson's reset point rather than pausing a Vim macro.

monogatari/host is the tester an app mounts. A Config names the host and what it runs:

cfg := host.Config{
    Prog:    "mtm story",                    // usage and messages; Name defaults to its slug, mtm-story
    Apps:    []monogatari.App{mtmApp()},     // the first is the default app
    Stories: storiesFS,                      // fs.Sub of the embedded stories/
}
root.Add(host.Command(cfg))                  // mtm story ... (an oden command named story)
// or, a tester binary of its own: host.Main(cfg, os.Args[1:], os.Stdout, os.Stderr)

and in the app's tests (Dir makes -update rewrite the goldens on disk):

func TestStories(t *testing.T) {
    cfg.Dir, cfg.Update = "stories", *update
    host.Test(t, cfg) // every story a parallel subtest; a skipped story is a gap
}

The commands: run, open, list, history, keep, replay, trim, explore (with --watch and --dedupe), verbs, the index TUI with no words, and the catalog (catalog, NOUN VERB --target ID):

noun verbs
story list (--app, --fixture), show, run, copy (--as NAME, to the user's stories with its golden), fixture, recordings, state
recording list (--story NAME), show, delete
fixture list (--app), show, stories
application list, show, stories, fixtures (the noun app is ochazuke's runtime's own)
verb, edge list (the minimum the stories exercise)

A relation is one handler on both nouns: story list --app A is application stories, fixture list --app A is application fixtures, recording list --story S is story recordings.

With no command, the index splits into the existing story lists on the left and a preview on the right. Moving focus updates the title, area, fixture, terminal size, script (keys and assertions), and first frame. Tutorials use the player's reset point and action tree. First frames are built outside the update loop, cached for this index session, and clipped to the pane; a loading placeholder keeps motions responsive while a build runs. Moving away cancels stale preview requests immediately. Fixture panics and build timeouts appear as wrapped preview errors alongside the known script, app and fixture names; one failed or stuck fixture does not hold up the next preview. First frames use the unfocused tone (dim-unfocused=0 disables dimming).

enter plays the focused story in the right pane with autoplay running. p opens the full-size player with autoplay from the list, or enlarges the current player while keeping its step and playback state. In a player opened from the pane, p toggles full size; q or esc returns one level at a time: full size → pane at the same step → the selected story. A player opened directly full size returns to the selected story, or p brings it into the pane. The zoom binding is available while index.preview is on. space pauses or resumes; l or right steps. o opens a story live for recording.

Player construction runs outside the update loop. The pane shows “Starting…” while keys remain available; moving focus or esc cancels startup. play.follow-focus=false keeps a start running after focus moves. play.start-indicator defaults to true and can hide that indicator. story.build-timeout defaults to 5s and must be positive; it bounds preview and player startup waits. The fixture API has no cancellation context, so an abandoned fixture can keep running until it returns, when its session is closed. Each preview/player slot runs at most one fixture; newer requests wait for its cleanup within the same build timeout. Startup errors and recording results use the full-width status line and clear on the next key. Hints come from the embedded host/bindings.json default rows, so user remaps appear in the footer and help. Enter falls through from the guarded in-pane binding to entry.open when the preview setting is off. The existing pane grow/shrink/reset bindings resize the split. The list keeps two rows around focus by default (scrolloff=2); scrolloff=0 disables that margin. While a story plays in the pane, its list row stays visible with a ▶ marker; index.playing-row=false disables this follow and marker.

index.preview defaults to true. Set it to false through the Settings page, settings set index.preview false, or the host's settings.json to restore the single-list index and live opening with enter. The CLI and TUI read the same host config directory; no app-specific wiring is required.

Browser and player defaults (all available in Settings):

Setting Default Effect
index.preview true Split index with cached preview; off restores the single list
play.start-indicator true Show “Starting…” while constructing a player
story.build-timeout 5s Bound preview and player startup waits; must be positive
play.follow-focus true Cancel startup when focus leaves its story
index.playing-row true Keep the playing story visible and mark it with ▶
scrolloff 2 Screen rows of context around focus; 0 disables the margin
play.inspector true State strip outside the app frame; alt+? toggles it
play.stop-after-action false Wait for space at the next reset after each action

The host declares scrolloff only when the framework has not declared it; an existing framework default or user value remains in charge.

Compared with Vim (checked against Vim 9.1's help):

Vim form :help tag Story index
j / k, gg / G j, k, gg, G Similar: existing ochazuke list motions, including area spills
/, ctrl+d / ctrl+u /, CTRL-D, CTRL-U Similar: existing list filter and half-page motions
:set scrolloff=2 'scrolloff' Same margin: the index scrolls by screen rows across area boundaries, keeping two lines around focus by default
ctrl+w o / :only, ctrl+w p CTRL-W_o, :only, CTRL-W_p Similar intent: p toggles full size while retaining the pane; esc returns one level with its step intact
— — Fixture construction, cancellation and timeouts: n/a; these have no Vim counterpart

Each host keeps its own state, under its name (Config.Name, default the slug of Prog): user stories (trimmed, copied, hand-made) in $XDG_CONFIG_HOME/NAME/stories, recordings (the ring of 5 and the saved ones) in $XDG_DATA_HOME/NAME. Two hosts on one machine never see each other's stories or recordings. ochazuke's o-story sets Name: "o-story", so its state stays put whichever command runs it.

Install

go get github.com/unixsuperhero/monogatari@latest

Requires ochazuke v0.54.0 or newer. For local development against sibling checkouts, use an untracked go.work.

In the family

monogatari sits above ochazuke and beside the apps' test code. No module below it requires it, and an app requires it only for its tester build:

Repo Relation
ochazuke monogatari requires it (Model, Runtime, cli, markdown, keytest). Its o-story tool becomes one more host, the host of ochazuke's demo app, in a nested module; the root module never imports monogatari.
oden The host is an oden command an app mounts, so mtm story resolves like any other subcommand.
matomeru (mtm) Its mtm-story tester mounts the host with fixtures seeded and empty and its stories under cmd/mtm/stories/. Planned.
o-json Runs its 14 stories through ochazuke's story package by hand today; moves to a tagged o-json story mount. Planned.

Release order: tag ochazuke, then monogatari, then bump ochazuke's nested modules and the apps' testers (see AGENTS.md, "Releasing").

Checks

gofmt -l . && GOWORK=off go vet ./... && GOWORK=off go test ./...
GOWORK=off go test -race ./...   # before a release

The logo is the Shiori: an open book whose left page is the story script and whose right page is the live terminal it plays against, with a red ribbon bookmark for the recording that marks your place. assets/logo-light.svg, assets/logo-dark.svg and assets/icon.svg are drawn by the family's logo generator.

License

MIT; see LICENSE.

Export checked stories for the web

Every host has story export [NAME|AREA...] --out DIR --format json. Like story run, it resolves bases, checks per-step and final expectations, and checks an ordinary script's golden when it has no final expectation. A failing or skipped story produces an error. The whole selection is checked before writing any output. Selection order follows the story files, with duplicates removed. Existing files outside index.json's inventory are not deleted.

The output is index.json plus <area>/<file-stem>.json per story. Version 1 records the story's name, app, fixture and cell size, and chapters containing steps with labels, captions, key caps, authored expectations and styled HTML. A tutorial has a setup chapter ending at its reset point, then one chapter per action. Each action includes its freshly replayed reset frame followed by its events. Ordinary scripts include the opening and each event. Jobs, ticks and restarts are labelled events; they do not invent keyboard presses.

Frames come from Session.Frame, just as in the player. Ultraviolet, Bubble Tea's cell model, decodes ANSI; adjacent cells with identical styles share an HTML span. Text is escaped, wide-cell continuations are omitted, colours and attributes are carried into CSS, and blink remains static. Default terminal colours use --frame-fg and --frame-bg CSS variables. Authored story themes are preserved, including mono when that is what the runner renders.

Exports have no timestamps. Use a stable, isolated TMPDIR when exporting: paths can affect clipping before a frame is captured. Export exclusively creates $TMPDIR/monogatari-export and refuses to reuse an existing directory; it removes only its own sandbox on completion. Fixtures should allocate their owned directories with Env.TempDir("fixture-name") and remove them in Close. This gives export a stable path and ordinary tests a random temp directory. A fixture that reads clocks, randomness, external services or other files must fake them as it does for headless tests. The same stories, fixture inputs and sandbox path produce byte-identical JSON, suitable for a fresh-export check.

Consumers read the index rather than scanning output directories. Each HTML fragment belongs inside a monospace pre with preserved whitespace; it is not a complete page. No browser player, app mock or external asset is required by the export format. The native index and player are unchanged.

Compared with Vim: exporting recorded app frames has no Vim counterpart.

Documentation

Overview

Package monogatari is the story library for apps built on ochazuke: the story format, the runner and driver, recordings and trimming, explore, and the host an app mounts to build its own story tester.

An app links monogatari only in its tester build, behind the build tag "monogatari"; a normal build of the app links none of it. ochazuke's own o-story is one more host of this library.

A story is data (a script of events and what must hold afterwards, plus the app, fixture and seed that build its starting state); Library.Build turns it into a live runtime and model through the app's fixture, a Driver plays events against it headless, and a Recorder turns the messages of a live session into the same events. State changes only through events; a frame is a pure function of the story and its events. Stories never read production state: a fixture gets only what Env gives it.

Index

Constants

View Source
const HistorySize = 5

HistorySize is how many unnamed recordings the ring keeps.

Variables

This section is empty.

Functions

func Check

func Check(ss *Session, e Expect) []string

Check reports what in e does not hold for the session's current frame.

func FrameHTML added in v0.3.0

func FrameHTML(frame string) string

FrameHTML serializes the player's trusted ANSI frame as escaped HTML. Adapted from tuios/shot/html.go's row/run writer. Ultraviolet handles ANSI decoding, grapheme widths and styles; this is only an HTML backend. Adjacent cells of the same style share a span. Wide-cell tails are omitted.

func Golden

func Golden(frame string) string

Golden is a frame as a golden file holds it: each row stripped of escapes behind a gutter that reads "R|" when the row has full-strength reverse video, "r|" when its only reverse video is dimmed (faint: an unfocused list's selected row, a highlight beneath a modal) and " |" otherwise, so goldens pin the highlight without colour. A highlighted row holding a table's current cell (reverse and underline together) reads "C|", dimmed "c|".

func Lookup

func Lookup(apps []App, s *Story) (*App, *Fixture, error)

Lookup resolves a story's app and fixture in apps: "" is the first.

func Slug

func Slug(name string) string

Slug is the file name stem of a name.

func Sort

func Sort(stories []*Story)

Sort orders stories by area, then file path.

func Unbatch

func Unbatch(t Tagged) (tea.Cmd, bool)

Unbatch turns a tagged batch into the batch of its wrapped parts.

func Wrap

func Wrap(gen int, cmd tea.Cmd) tea.Cmd

Wrap tags every message cmd produces with gen; a batch is unwrapped by the receiver (Unbatch) and its parts wrapped again.

func WriteStory

func WriteStory(dir string, s *Story) (string, error)

WriteStory writes a story as <dir>/<area>/<slug>.json.

Types

type Action added in v0.2.0

type Action struct {
	ID      string  `json:"id,omitempty"` // optional stable identity; Label is for people
	Label   string  `json:"label"`
	Caption string  `json:"caption,omitempty"`
	Keys    []Event `json:"keys"`
	Expect  Expects `json:"expect,omitempty"`
	Notes   []Note  `json:"notes,omitempty"`
}

Action is one independent lesson, played after Setup. Keys accepts the same strings and events as Script, including job deliveries and notes.

type App

type App struct {
	Name    string
	Summary string
	// Fixtures are the app's named starting states; the first is the
	// default for a story that names none.
	Fixtures []Fixture
	// Fields are the top-level story fields the app reads beyond the
	// story format's own (the demo's page and pages, Story.Fields). A
	// story with a field its app does not declare fails to build.
	Fields []string
	// Expected reports an operation error the app means (a refusal of its
	// fakes), which the explorer must not report; nil is none.
	Expected func(op, err string) bool
}

App is an application stories can build; every host registers its own (Library.Apps). monogatari has no built-in app.

type Built

type Built struct {
	Model *ochazuke.Model
	// Program is the app's own tea.Model when it wraps the ochazuke
	// model's Update and View; nil drives Model.
	Program tea.Model
	// Init runs Program's (or Model's) Init before the first event; false
	// runs only Runtime.PendingCommands.
	Init bool
	// Start runs once the host has loaded the story's config, hung its
	// checks, sized the session and drawn the first frame (jobs a story
	// starts, a scroll offset).
	Start func() error
	// Close releases the fixture's fakes; the host removes the temp dirs.
	Close func()
	// Emit makes every live fake pane print a line (the output tick),
	// reporting whether one was live; HasPane reports one is live.
	Emit    func() bool
	HasPane func() bool
	// Writes is what the app wrote, for the writes expect and dirty
	// checks; nil is none.
	Writes func() []string
	// Snapshot bakes the session's current state into a story's app data,
	// for trim --snapshot; nil cannot.
	Snapshot func() (Snapshot, error)
}

Built is what a fixture made.

type CheckSpec

type CheckSpec struct {
	Verb   string `json:"verb,omitempty"`
	Name   string `json:"name,omitempty"`
	Ask    string `json:"ask,omitempty"`
	Refuse string `json:"refuse,omitempty"`
	Dirty  bool   `json:"dirty,omitempty"`
}

CheckSpec is one verb check: on Verb (default app.quit), Ask or Refuse while the page's first editable tree has unwritten changes (Dirty: any write since the story started), else always (Dirty false).

type Driver

type Driver struct {
	S    *Session
	Opt  Options
	Wait time.Duration // how long a job or tick event waits for its message
	Quit bool          // the script quit the story (q, ctrl+c)

	// Trace is one Step per event played: the mode before and after and
	// the top-level noun.verb requests the event ran.
	Trace []Step
	// contains filtered or unexported fields
}

Driver plays events against a session with no terminal: commands run on goroutines and their messages wait in a pool; a job or tick event takes the first matching message from the pool (waiting for it) and delivers it, so nothing reaches the model except through an event, in order. The model runs on the driver's fake clock (Model.Now), which stands still between events; a linger tick advances it to the timer's deadline and delivers the timer's message at once (Model.JobLingerTick), so a story never waits JobLinger.

func NewDriver

func NewDriver(ss *Session, opt Options) *Driver

NewDriver starts driving a built session.

func (*Driver) Arrived

func (d *Driver) Arrived() []Event

Arrived delivers every pooled message of this generation that is an event (a job's completion, a pane frame, a framework timer) in arrival order, each as a traced step, and returns the events it played. A live recorder calls it between keys so the session follows its jobs without a script naming them; a scripted run never needs it.

func (*Driver) Close

func (d *Driver) Close()

Close ends the session's fake goroutines.

func (*Driver) Play

func (d *Driver) Play(events []Event, after func(n int) error) error

Play runs events in order, calling after (when set) with the count of events played so far after each one.

func (*Driver) Step

func (d *Driver) Step(e Event) error

Step plays one event and appends its Step to Trace.

type Edge

type Edge struct {
	From, To string
	Via      string // noun.verb (joined when a step ran several), or the row/tick that did it
}

Edge is a mode change one step made.

type Entry

type Entry struct {
	N    int
	Name string
	Path string
	Rec  *Recording
}

Entry is one recording on disk; N is its place in the ring, 1 newest (0 for a saved one).

type Env

type Env struct {
	Story *Story
	// Raw is the whole story document (Story's own fields and Fields).
	Raw  json.RawMessage
	Seed json.RawMessage
	// StateDir is the session's state directory (Story.State; kept across
	// a restart), ConfigDir the temp directory the story's config files
	// are written to (Story.Config), FilesDir its file tree (Story.Files);
	// each "" when the story has none.
	StateDir, ConfigDir, FilesDir string
	// Now is the session's clock: the driver's fake clock once a driver
	// runs the session, the epoch before.
	Now           func() time.Time
	Width, Height int
	Options       Options
	// Reset asks the host to rebuild the session from its story after the
	// event being played (a reset verb).
	Reset func()
	// contains filtered or unexported fields
}

Env is everything a fixture may use: the story, its app data and the temp directories the host made for it.

func (Env) Field

func (e Env) Field(name string, v any) (bool, error)

Field decodes the story's app field name into v strictly, reporting whether the story has it.

func (Env) NewRuntime

func (e Env) NewRuntime() (*ochazuke.Runtime, *ochazuke.Model, error)

NewRuntime makes a sandboxed runtime and model for a fixture that builds its app from scratch: user config off, state only in StateDir, the story's settings declared and its values set, a headless foreground, and the story's theme.

func (Env) Sandbox

func (e Env) Sandbox(m *ochazuke.Model) error

Sandbox applies NewRuntime's sandbox to a model the app built itself. The host calls it after Build when the fixture did not; a fixture calls it itself when its app must see the story's settings while it builds.

func (Env) TempDir added in v0.3.0

func (e Env) TempDir(name string) (string, error)

TempDir creates a fixture-owned directory. Close must remove it. During export its name is stable within the exclusively owned export sandbox; ordinary runs and tests get a random directory. Name must be one path part.

type Event

type Event struct {
	T     int64  `json:"t,omitempty"`
	Key   string `json:"key,omitempty"`
	Type  string `json:"type,omitempty"`
	Paste string `json:"paste,omitempty"`
	Job   string `json:"job,omitempty"`   // noun.verb of the job that delivered
	State string `json:"state,omitempty"` // done or failed
	Tick  string `json:"tick,omitempty"`  // linger, binding, terminal (a pane frame), output (fake output line), debounce (a tree line's, a form's or a completion's pause) or segment (the status segments' next refresh, answered)
	Note  string `json:"note,omitempty"`  // shown while this event plays
	// Restart quits the session and builds it again from the story with
	// the same state directory (State), as a user reopening the app.
	Restart bool `json:"restart,omitempty"`
}

Event is one action: a key press, typed text, a paste, a job delivering, or a framework timer firing. T is milliseconds since the session started (recordings only; scripts leave it 0).

func Classify

func Classify(ss *Session, msg tea.Msg) *Event

Classify names the event a message is, before it is delivered: a key, a paste, a current job's completion, a pane frame, a framework timer. Other messages (window size, colours) are not events.

func (Event) MarshalJSON

func (e Event) MarshalJSON() ([]byte, error)

MarshalJSON writes an untimed key press as a bare string.

func (Event) String

func (e Event) String() string

String is the event as a script reads it.

func (*Event) UnmarshalJSON

func (e *Event) UnmarshalJSON(b []byte) error

UnmarshalJSON reads a bare string as a key press.

type Expect

type Expect struct {
	After int     `json:"after,omitempty"`
	Focus string  `json:"focus,omitempty"`
	Mode  string  `json:"mode,omitempty"`
	Modal *string `json:"modal,omitempty"` // top modal instance, "" for none
	Quit  *bool   `json:"quit,omitempty"`  // Runtime.Quit: app.quit ran
	Page  string  `json:"page,omitempty"`
	// Shift is the current page's pane divider (Runtime.PaneShift).
	Shift   *int  `json:"shift,omitempty"`
	Visible *bool `json:"visible,omitempty"` // the focused element's label is on screen
	// Highlighted counts rows holding a full-strength highlight (reverse
	// video not under faint); Dimmed counts rows holding a dimmed one (an
	// unfocused page list's selected row, a highlight beneath a modal).
	// Dimmed is read in the mono theme, where a dim is the faint attribute.
	Highlighted *int `json:"highlighted,omitempty"`
	Dimmed      *int `json:"dimmed,omitempty"`
	// HighlightedText is text every highlighted row holds (the selected row's label).
	HighlightedText string `json:"highlightedText,omitempty"`
	// Cell is the text of a table's current cell (drawn reverse and
	// underlined inside its row, dimmed or not); "" for none.
	Cell *string `json:"cell,omitempty"`
	// Badge is the mode badge at the right of the title row (full
	// chrome): its whole text, padded by a cell on each side, followed by
	// the end of the row or a " · " (the focus display; a recording's
	// "· REC @q" too, so expect the whole badge while recording) (R2.10).
	Badge    string   `json:"badge,omitempty"`
	Contains []string `json:"contains,omitempty"`
	Lacks    []string `json:"lacks,omitempty"`
	Order    []string `json:"order,omitempty"` // substrings that appear on screen in this order
	Ticker   []string `json:"ticker,omitempty"`
	NoTicker bool     `json:"noTicker,omitempty"`
	// Segments are the status segments the header rule draws
	// (Model.StatusSegments); NoSegments expects none.
	Segments   []string `json:"segments,omitempty"`
	NoSegments bool     `json:"noSegments,omitempty"`
	Notices    []string `json:"notices,omitempty"`
	NoNotices  bool     `json:"noNotices,omitempty"`
	// Colored maps on-screen text to a "#RRGGBB" foreground some row holding
	// that text draws in; NoColor lists foregrounds no cell may use.
	Colored map[string]string `json:"colored,omitempty"`
	NoColor []string          `json:"noColor,omitempty"`
	// Styled maps on-screen text to whether it starts a run drawn in Hex
	// ("#RRGGBB"): the SGR right before the text sets that foreground,
	// cell-exact where Colored is row-wide. "!#RRGGBB" expects any other.
	Styled map[string]string `json:"styled,omitempty"`
	// Attrs maps on-screen text to the attributes its first cell is drawn
	// with, followed along its row's SGR state: a comma list of bold,
	// faint, reverse, underline, fg (any foreground colour) and bg (any
	// background colour), each
	// "-name" for absent. "bold,-fg" reads a mono tone.
	Attrs map[string]string `json:"attrs,omitempty"`
	// Writes is every change the page's editable trees, record editors and
	// Markdown documents wrote, in order (TreeChange.String,
	// RecordWrite.String, "toggle LINE [x] TEXT"); [] expects none.
	Writes *[]string `json:"writes,omitempty"`
	// Register is the unnamed register's text (a yank).
	Register *string `json:"register,omitempty"`
}

Expect is what must hold after a step (After events) or at the end (After 0). Every field is optional.

type Expects

type Expects []Expect

Expects is one final Expect or a list of per-step ones.

func (Expects) MarshalJSON

func (x Expects) MarshalJSON() ([]byte, error)

func (*Expects) UnmarshalJSON

func (x *Expects) UnmarshalJSON(b []byte) error

type ExportChapter added in v0.3.0

type ExportChapter struct {
	ID      string       `json:"id,omitempty"`
	Label   string       `json:"label"`
	Caption string       `json:"caption,omitempty"`
	Steps   []ExportStep `json:"steps"`
}

type ExportStep added in v0.3.0

type ExportStep struct {
	Label   string   `json:"label"`
	Caption string   `json:"caption,omitempty"`
	Keys    []string `json:"keys"`
	Expect  Expects  `json:"expect"`
	HTML    string   `json:"html"`
}

type ExportedStory added in v0.3.0

type ExportedStory struct {
	Version  int             `json:"version"`
	ID       string          `json:"id"`
	Name     string          `json:"name"`
	Area     string          `json:"area"`
	App      string          `json:"app"`
	Fixture  string          `json:"fixture"`
	Width    int             `json:"width"`
	Height   int             `json:"height"`
	Chapters []ExportChapter `json:"chapters"`
}

ExportedStory is a portable replay with no capture timestamps. Version changes when the export schema changes.

type Fixture

type Fixture struct {
	Name, Summary string
	// Build makes the app's runtime for a story. It reads only what Env
	// gives it: nothing else on the machine.
	Build func(Env) (Built, error)
}

Fixture is one named starting state of an app.

type Library

type Library struct {
	Stories []*Story
	Store   Store
	Apps    []App
}

Library is every story a run can name (embedded and the user's), the recordings store a trimmed story's base may name, and the apps whose fixtures build the stories (the first is the default; there is no built-in app).

func (Library) Build

func (lib Library) Build(s *Story, opt Options) (*Session, error)

Build makes a session of a story through its app's fixture. A trimmed story's base is resolved by the caller (Resolve) before Build.

func (Library) Export added in v0.3.0

func (lib Library) Export(s *Story) (ExportedStory, error)

Export runs the same driver and assertions as Run, including golden checks for ordinary scripts and a fresh fixture and setup for each action. It returns no replay for a failing or skipped story.

func (Library) Replay

func (lib Library) Replay(rec *Recording, expect Expects) Result

Replay plays a recording headless: its events are the script, and only expect (the recording's own when nil) is asserted.

func (Library) Resolve

func (lib Library) Resolve(s *Story) (*Story, []Event, error)

Resolve turns a trimmed story into the base's initial story with this story's script and expectations, plus the base events to replay first. A base is "story:NAME" (its script) or "recording:NAME" (a saved recording's events).

func (Library) Run

func (lib Library) Run(s *Story, opt RunOptions) (res Result)

Run plays a story headless and checks it: each per-step expect after its step, the final expect at the end, else the golden frame.

func (Library) Subject

func (lib Library) Subject(s *Story) explore.Subject

Subject explores a story's application: every replay builds the story afresh through its app's fixture (its fakes included), drives the app's own program when the fixture has one, and closes it after. The app's Expected refusals are not findings.

func (Library) Trim

func (lib Library) Trim(base string, initial *Story, events []Event, t TrimSpec) (*Story, []string, error)

Trim writes a story of events[from..to] of the source. Without Snapshot it names its base ("recording:NAME" or "story:NAME") and the runner replays events[:from] headless first; with Snapshot it plays them now and bakes the state they reach (lists, rows, selection, marks, focus, page offset) into the story's pages, so the story stands alone. The expect is the state observed at the end of the window: edit it to the correct state to make the story fail until the bug is fixed.

type Minimum

type Minimum struct {
	Verbs map[string]map[string][]string // mode -> noun -> verbs
	Edges []Edge
}

Minimum is what a set of stories exercises: per mode and noun the noun.verb requests their keys resolved to (the minimum a component needs to be full-featured; which keys reach them is the app's choice), and the edges between modes with what took them.

func Derive

func Derive(traces ...[]Step) Minimum

Derive collects the minimum from story traces.

func (Minimum) Markdown

func (m Minimum) Markdown() string

Markdown renders the minimum as the README's tables.

func (Minimum) Trapped

func (m Minimum) Trapped() []string

Trapped lists the modes the stories enter from which no chain of edges leads back to normal: each needs an exit (terminal a detach, insert an esc or cancel, a pending mode a timeout or esc).

type Note added in v0.2.0

type Note struct {
	After int    `json:"after"`
	Keys  string `json:"keys,omitempty"` // optional key legend, kept for older notes
	Note  string `json:"note"`
}

Note is narration at a script, setup or action position.

type Options

type Options struct {
	Mono bool // the mono theme: no colour, as goldens compare
	// TempRoot is an exclusively owned export sandbox. Empty uses random temp
	// directories. Fixtures must use Env.TempDir for deterministic exports.
	TempRoot string
}

Options configure a headless session.

type Ran

type Ran struct {
	Mode string
	Verb string // noun.verb
	OK   bool
}

Ran is one request a step ran.

type Recorder

type Recorder struct {
	Rec Recording
	// contains filtered or unexported fields
}

Recorder collects a session's events as they happen.

func NewRecorder

func NewRecorder(s *Story, now func() time.Time) *Recorder

NewRecorder starts a recording of s now.

func (*Recorder) Add

func (r *Recorder) Add(e Event)

Add appends an event stamped with the time since the start.

type Recording

type Recording struct {
	Story   string    `json:"story"`
	Started time.Time `json:"started"`
	Initial *Story    `json:"initial"`
	Events  []Event   `json:"events"`
	Expect  Expects   `json:"expect,omitempty"`
}

Recording is one live session in a story: the initial story and the ordered events (keys, job deliveries, timer ticks) with their times. Replaying the events against the initial story reproduces every frame.

type Result

type Result struct {
	Story   *Story
	Failed  []string
	Err     error
	Skipped bool   // the story carries a skip ("gap: ..."): it ran but cannot fail
	Updated bool   // --update wrote the golden
	Trace   []Step // the window's steps (the base's prefix excluded)
}

Result is one story's outcome.

func (Result) OK

func (r Result) OK() bool

OK reports a pass (or a skip).

type RunOptions

type RunOptions struct {
	Update bool // rewrite goldens
	Golden bool // compare the final frame to a golden when the story has no final expect
	// Observe sees the opening and each completed event, using the same driver
	// and assertions as Run. The session is valid only during the callback.
	Observe func(RunPosition)
	// contains filtered or unexported fields
}

RunOptions control one run.

type RunPosition added in v0.3.0

type RunPosition struct {
	Session *Session
	Action  int
	After   int
}

RunPosition is a completed position in Run. Action -1 is setup or an ordinary script; After 0 is the opening, before any segment events.

type Session

type Session struct {
	Story *Story
	R     *ochazuke.Runtime
	M     *ochazuke.Model
	// Program is the app's tea.Model when it wraps M; nil drives M.
	Program tea.Model
	// Reset is set by Env.Reset; the host (TUI or Driver) rebuilds.
	Reset bool
	// StateDir is the state directory of a story with State, "" without;
	// Close removes it unless the session is restarting into it.
	StateDir string
	App      *App
	Fixture  *Fixture
	// contains filtered or unexported fields
}

Session is one built story: the runtime, the model, the app's own program, and what the host made for it.

func (*Session) Close

func (ss *Session) Close()

Close releases the fixture's fakes and the session's temp dirs.

func (*Session) Cursor

func (ss *Session) Cursor() *tea.Cursor

Cursor is the terminal cursor of the last Frame, relative to the story's area, or nil.

func (*Session) Emit

func (ss *Session) Emit() bool

Emit makes every live fake pane print one more output line (the "output" tick), reporting whether one was live.

func (*Session) Frame

func (ss *Session) Frame() string

Frame renders the story's area: exactly Width x Height cells of trusted ANSI, by the story's chrome mode.

func (*Session) HasPane

func (ss *Session) HasPane() bool

HasPane reports whether a fake pane is live.

func (*Session) Rebuild

func (ss *Session) Rebuild(restart bool) (*Session, error)

Rebuild builds the session again from its story, closing this one; a restart keeps the state directory, a reset starts with a new one.

func (*Session) Snapshot

func (ss *Session) Snapshot() (Snapshot, error)

Snapshot bakes the session's current state into story data.

func (*Session) Start

func (ss *Session) Start() tea.Cmd

Start is the command that starts the session: the program's Init when the fixture asks for it, else the runtime's pending commands.

func (*Session) Update

func (ss *Session) Update(msg tea.Msg) tea.Cmd

Update delivers a message to the app's program (the model when it has none).

func (*Session) View

func (ss *Session) View() tea.View

View renders the app's program (the model when it has none).

func (*Session) Writes

func (ss *Session) Writes() []string

Writes is what the app wrote (Built.Writes).

type Snapshot

type Snapshot struct {
	Fields map[string]json.RawMessage
	Seed   json.RawMessage
	Warn   []string
}

Snapshot is a session's current state as story data: the fields and the seed of a story that starts there, and what it cannot carry.

type Step

type Step struct {
	Event  Event
	Before string
	After  string
	Ran    []Ran
}

Step is what one event did: the mode it was read in, the mode after it and the top-level requests it ran (noun.verb in the mode each ran in).

type Store

type Store struct{ Dir string }

Store is a host's data directory: history/ is the ring of the last HistorySize recordings, saved/ the named ones, which the ring never drops.

func DefaultStore

func DefaultStore(getenv func(string) string, host string) Store

DefaultStore is the store of the host named host (its slug: o-story, mtm-story): $XDG_DATA_HOME/HOST, else ~/.local/share/HOST. Each host has its own ring and saved recordings.

func (Store) Delete

func (s Store) Delete(ref string) (string, error)

Delete removes a recording by ring number (1 newest) or saved name, returning the file it removed. Deleting a ring entry renumbers the older ones.

func (Store) History

func (s Store) History() ([]Entry, error)

History lists the ring, newest first (N = 1).

func (Store) Keep

func (s Store) Keep(n int, name string) (string, error)

Keep promotes history entry n under name.

func (Store) Load

func (s Store) Load(ref string) (*Entry, error)

Load finds a recording by ring number (1 newest) or saved name.

func (Store) Move

func (s Store) Move(path, name string) (string, error)

Move promotes a recording file out of the ring into saved/<name>.json.

func (Store) Push

func (s Store) Push(rec *Recording) (string, error)

Push writes a recording into the ring and drops the oldest past HistorySize. It returns the file written.

func (Store) Save

func (s Store) Save(rec *Recording, name string) (string, error)

Save writes rec as saved/<name>.json.

func (Store) Saved

func (s Store) Saved() ([]Entry, error)

Saved lists the named recordings.

type Story

type Story struct {
	Name   string `json:"name"`
	Area   string `json:"area"`
	Skip   string `json:"skip,omitempty"` // "gap: ..." marks a known framework gap; the story runs but cannot fail the suite
	Width  int    `json:"width,omitempty"`
	Height int    `json:"height,omitempty"`
	Chrome string `json:"chrome,omitempty"` // full (default), none (page body only) or ticker-only
	// Theme renders the story in a theme other than mono (adaptive, dark,
	// light); Accent ("#RRGGBB") is the Appearance accent override. Goldens
	// stay escape-free, so a coloured story asserts colours with colored.
	Theme  string `json:"theme,omitempty"`
	Accent string `json:"accent,omitempty"`
	// Themes are colour schemes in the themes.json shape the session adds
	// to the built-in themes (Theme may name one); a scheme that does not
	// validate is a notice, as in an application.
	Themes json.RawMessage `json:"themes,omitempty"`
	// App names the application whose fixture builds the session ("" is
	// the host's first app); Fixture names one of its starting states (""
	// is the app's first); Seed is app-defined data handed to the fixture.
	App     string          `json:"app,omitempty"`
	Fixture string          `json:"fixture,omitempty"`
	Seed    json.RawMessage `json:"seed,omitempty"`
	// Base and Start make a trimmed story: replay the base's events before
	// Start headless, then play Script as the window under test.
	Base   string  `json:"base,omitempty"`
	Start  int     `json:"start,omitempty"`
	Script []Event `json:"script,omitempty"`
	// Setup reaches a tutorial's reset point. Each action starts in a fresh
	// session with Setup replayed; actions never inherit one another's edits.
	Setup   []Event  `json:"setup,omitempty"`
	Actions []Action `json:"actions,omitempty"`
	// Notes narrate script/setup positions (After 0 is the opening frame).
	Notes []Note `json:"notes,omitempty"`
	// Settings are declared on the session's runtime; with no config
	// directory (stories read no user files) their values last the session.
	Settings []ochazuke.Setting `json:"settings,omitempty"`
	// Values are setting values the session starts with, set as
	// settings.json would hold them (Settings.Set; with no config
	// directory they last the session): the saved layer.
	Values map[string]any `json:"values,omitempty"`
	// Session are session values set at the start, above Values (the
	// session started as Model.Init starts it, then Settings.SetSession):
	// what a :set line leaves unwritten.
	Session map[string]any `json:"session,omitempty"`
	// State gives the session a state directory of its own (a temp dir,
	// removed when the session ends), kept across a restart event, so a
	// story shows what the application remembers (pane dividers).
	State bool `json:"state,omitempty"`
	// Config is the user's config files the session starts with, by file
	// name: written into a temp config directory of its own (removed at
	// the end); actions.json and then bindings.json are loaded the way
	// Model.Init loads them, after the pages are built. A problem a load
	// reports is a notice.
	Config map[string]json.RawMessage `json:"config,omitempty"`
	// Checks hang verb checks on the session's verbs (Runtime.AddVerbCheck;
	// app.quit is BeforeQuit's), after the pages are built.
	Checks []CheckSpec `json:"checks,omitempty"`
	// Files is a file tree the session makes in a temp directory of its
	// own (removed at the end): paths relative to it, a trailing / for a
	// folder. {{files}} in a record field's base or value names it, and
	// writes show it as {{files}}.
	Files  []string `json:"files,omitempty"`
	Expect Expects  `json:"expect,omitempty"`

	// Fields are the story's other top-level fields, the ones an app
	// reads (App.Fields: the demo's page and pages), kept as written. A
	// field the story's app does not declare fails the build.
	Fields map[string]json.RawMessage `json:"-"`

	// Path is where the file was loaded from (not part of the data); rel
	// is the path inside fsys, disk the directory fsys reads when it is on
	// disk (goldens are written there).
	Path string `json:"-"`
	// contains filtered or unexported fields
}

Story is one scenario file.

func Find

func Find(stories []*Story, name string) *Story

Find returns the story whose name, slug or area/slug is name.

func LoadDir

func LoadDir(dir string) ([]*Story, error)

LoadDir reads the stories under a directory on disk; a missing directory is no stories.

func LoadFS

func LoadFS(fsys fs.FS, root string) ([]*Story, error)

LoadFS reads every <area>/<name>.json under root in fsys.

func Parse

func Parse(data []byte, where string) (*Story, error)

Parse reads one story file.

func (*Story) EventCount added in v0.2.0

func (s *Story) EventCount() int

EventCount is the authored event count (setup once, each action once). Reset replays do not inflate the count in the host's index.

func (*Story) Field

func (s *Story) Field(name string, v any) (bool, error)

Field decodes the top-level field name into v strictly, reporting whether the story has it.

func (*Story) GoldenPath

func (s *Story) GoldenPath() string

GoldenPath is the golden frame file beside the story.

func (Story) MarshalJSON

func (s Story) MarshalJSON() ([]byte, error)

MarshalJSON writes Story's own fields, then Fields in name order.

func (*Story) ReadGolden

func (s *Story) ReadGolden() ([]byte, error)

ReadGolden reads the golden frame, or reports fs.ErrNotExist.

func (*Story) Size

func (s *Story) Size() (int, int)

Size is the story's width and height (80x24 unless given).

func (*Story) UnmarshalJSON

func (s *Story) UnmarshalJSON(b []byte) error

UnmarshalJSON reads Story's own fields strictly and keeps every other top-level field in Fields, for the story's app to read.

func (*Story) WriteGolden

func (s *Story) WriteGolden(frame string) error

WriteGolden writes the golden frame beside a story loaded from disk.

type Tagged

type Tagged struct {
	Gen int
	Msg tea.Msg
}

Tagged is a message from a session's command, tagged with the session's generation so a message of a session that was reset or left never reaches its successor (job ids restart with each runtime).

type TrimSpec

type TrimSpec struct {
	From, To int    // inclusive event indexes (0-based); -1 = unset
	At       int    // the failing event; From/To default to At-3 and At+2; -1 = unset
	As       string // the new story's name
	Snapshot bool   // bake the before-state into the story's app data (Built.Snapshot) instead of naming a base
}

TrimSpec chooses a window of a recording's (or story's) events.

func (TrimSpec) Window

func (t TrimSpec) Window(n int) (int, int, error)

Window resolves From and To against n events. A window is never a single frame: it holds at least two events.

Directories

Path Synopsis
Package explore explores an ochazuke application's interaction states by pressing keys, checks properties in every state it reaches, and reports bad and impossible states and actions with the shortest key path to each.
Package explore explores an ochazuke application's interaction states by pressing keys, checks properties in every state it reaches, and reports bad and impossible states and actions with the shortest key path to each.
Package host is a monogatari host: the story tester an app mounts.
Package host is a monogatari host: the story tester an app mounts.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL