sugarcraft / sugar-testing
Test harness for SugarCraft terminal programs — golden snapshots, cell-grid assertions, program simulation, scripted input, VHS tape building, and graphics decoding.
Requires
- php: ^8.3
- react/event-loop: ^1.6
- sugarcraft/sugar-cellbuffer: @dev
- sugarcraft/sugar-core: @dev
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
- ext-gd: Pixel materialization in Graphics assertions (the pixel paths fail loudly without it)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 01:02:59 UTC
README
Formerly published as
sugarcraft/candy-testing(renamed 2026-10).
Deterministic test harness for SugarCraft apps — MVC-style Model–Update–View programs — for PHP 8.3+: scripted input, golden snapshots, cell-grid assertions, program simulation, VHS tape building, and graphics-stream decoding.
Architecture background: SugarCraft apps follow the MVC-style Model–Update–View architecture. Testing such programs deterministically has been a long-standing gap —
sugar-testingcloses it for the PHP ecosystem.
Overview
sugar-testing provides the infrastructure for deterministic SugarCraft app testing:
ProgramSimulator— drives aModelorProgramwith scripted messages, intercepting control messages at the dispatch layer exactly asProgramdoes (see Simulation subset)ScriptedInput— fluent builder for message sequences (->key('q')->enter())- Snapshot assertions —
assertGoldenAnsi,assertCellGrid,assertAnsiEquals,assertViewIdempotent— every shape that cannot assert anything fails loudly; a silent green is never produced by a malformed expectation GoldenFile— lifecycle-aware.goldenhelper:load()(null only when missing; unreadable or symlinked goldens throw),save()(atomic clobber — fixture seeding),create()(one-way, refuses an existing golden),update()(deliberate re-baseline),resolve()(fixtures-relative path with a traversal and symlink jail)TestResult— whatrun()returns: final model, last view, captured cmds, raw output, per-turnframes, anddroppedAfterStopGraphics\— inline-image stream assertions (KittyStream,Iterm2Stream,SixelStream,PixelGrid,Mode,DecodeLimits,MalformedGraphicsException) — consumed by sugar-imageLoopPin— pins the shared ReactPHP loop toStreamSelectLoopso safety timers keep their real deadlines (see below)TapeRecorder— builds VHS-compatible.tapefiles for demo rendering, with an escape guarantee: typed text can never forge a standalone directive lineTemporaryDirectoryTrait— unique, mode-0700, symlink-safe scratch dirs for filesystem tests
Requirements
- PHP 8.3+
sugarcraft/sugar-core(Program, Msg, Model, Cmd)sugarcraft/sugar-cellbuffer(Buffer for cell-grid assertions)react/event-loop(the loopLoopPinswaps)ext-gd— only needed to materialize pixels inGraphicsassertions; it is asuggest, not a hard requirement, and streams can be decoded without it
Install
composer require sugarcraft/sugar-testing:@dev
Quickstart
This block runs exactly as copy-pasted (the suite executes it as a test):
use SugarCraft\Core\Msg; use SugarCraft\Core\Msg\KeyMsg; use SugarCraft\Testing\Input\ScriptedInput; use SugarCraft\Testing\ProgramSimulator; use SugarCraft\Testing\Snapshot\Assertions; use SugarCraft\Testing\Snapshot\GoldenFile; // The smallest drivable counter — inline so this block is self-contained. // A real suite hands the simulator its own app's Model (or Program). $counter = new class implements \SugarCraft\Core\Model { public function __construct(private int $count = 0) { } public function init(): ?\Closure { return null; } public function update(Msg $msg): array { if ($msg instanceof KeyMsg && $msg->rune === 'a') { return [new self($this->count + 1), null]; } return [$this, null]; } public function view(): string { return 'count: ' . $this->count; } public function subscriptions(): ?\SugarCraft\Core\Subscriptions { return null; } }; $result = ProgramSimulator::for($counter) ->send(...ScriptedInput::new()->key('a')->key('q')->build()) ->run(); // Real suites COMMIT the golden file; a missing golden then fails loudly (see // Assertions). The guarded save() below just stands in for that first seeding. $golden = sys_get_temp_dir() . '/sugar-testing-quickstart.golden'; if (GoldenFile::load($golden) === null) { GoldenFile::save($golden, $result->view); } Assertions::assertGoldenAnsi($golden, $result->view);
send() takes one message or many — pairing ScriptedInput with the simulator
is a single spread: $sim->send(...$input->build()); (equivalently, a
foreach of send() calls over $input->build()).
API
ProgramSimulator
use SugarCraft\Testing\ProgramSimulator; $sim = ProgramSimulator::for($program); // Model or Program $sim->send($msgA, $msgB); // enqueue (fluent, variadic) $sim->withRealCmdRunner(false); // capture-only: cmds collected, never executed $sim->withFakeCmdRunner(fn ($cmd) => $nextMsg); // substitute the Cmd result $result = $sim->run(); // replayable from the initial state echo $result->view; // last view() string echo $result->output; // every view() line, concatenated $finalModel = $result->model; // the model object — inspect it, don't echo it
Queue and clone discipline. send() mutates the handle you call it on; the
with*() configuration setters return a clone that copies the queue by
value at clone time. Configure first, then send — sending on the original
handle after cloning leaves the clone's queue (and therefore its run())
without those messages. run() resets all drain state and replays from the
initial model, so repeated runs are identical.
Simulation subset
Program::dispatch() intercepts control messages before your update() ever
sees them. The simulator mirrors that layer message for message — but it is a
simulator, not the real runtime, and the boundary is explicit:
BatchMsg/SequenceMsg— each member closure runs as a Cmd would; the messages they produce are dispatched. Members must be callables; anything else throws. The container itself never reachesupdate().TickRequest—produce()fires immediately. There is no virtual clock: the requested delay is not simulated, and nothing is armed on a loop.RawMsg/PrintMsg— written to$result->outputas a side channel; no model turn, no frame.QuitMsg/InterruptMsg— halt the drain, production-style: the model never sees them, and any messages still queued when the halt lands are counted in$result->droppedAfterStopinstead of vanishing silently.ScriptedInput::quit()sends the real thing.SuspendMsg— consumed (there is no terminal to suspend).ExecRequest— throws aRuntimeException.execsuspends onto a real terminal; the simulator has none, so it refuses rather than faking a pass.- Startup trio —
ProgramdispatchesWindowSizeMsg,EnvMsgandColorProfileMsgbeforeinit(). The simulator replays that order only when armed, so a default run stays byte-identical to pre-parity suites:->withWindowSize(80, 24)->withEnvironment($vars)->withColorProfile($profile). - Subscriptions are fired after
init()and after each delivered message, asProgramdoes. A runaway cmd↔message loop is refused at 10,000 update cycles — a fixed bound, not a growable one.
Assertions
use SugarCraft\Testing\Snapshot\Assertions; // Golden ANSI snapshot. Argument order: (golden path, actual bytes). Assertions::assertGoldenAnsi('tests/fixtures/view.golden', $actual); // Cell-grid diff against a Buffer — schema below. Assertions::assertCellGrid($grid, $buffer); // Byte-exact ANSI comparison with readable diff on failure. Assertions::assertAnsiEquals("\x1b[1;32mHello\x1b[0m", $actual); // Render purity: view() must be idempotent and must not mutate the model. Assertions::assertViewIdempotent($model);
Golden comparison is BYTE-EXACT — no SGR stripping and no line-1 header skip (machine-stamped golden headers are a sugar-dash convention, handled by that lib's own comparator). A golden here must be byte-stable across machines.
Missing goldens FAIL. On a fresh checkout the run fails rather than
silently green-ing itself; the failure message points at the create flag.
SUGAR_TESTING_UPDATE_GOLDENS=1 (legacy alias UPDATE_GOLDENS=1) is
CREATE-ONLY: the first run creates the file — and counts a read-back assertion
proving what it wrote — but an existing golden that differs still fails and
is never rewritten. To re-baseline deliberately, delete the file and rerun
with the flag set (that is also the CI workflow: no flag is ever set in CI, so
CI only ever compares).
The cell-grid schema (assertCellGrid)
The grid is a list of rows compared against a SugarCraft\CellBuffer\Buffer.
Every part of the shape is parsed at the boundary and nothing is silently
skipped — a grid that cannot assert anything fails, so a typo can never
produce a false green:
// A plain string row spreads its grapheme clusters across cells, one per // cell. This matches a 1-row, 4-column buffer painted "abcd": Assertions::assertCellGrid(['abcd'], $buffer); // Per-cell forms — each row is exactly `buffer->width()` cells long: Assertions::assertCellGrid([ ['ok', ['rune' => '🎉', 'width' => 2], null], // string cell, array form (wide glyph claims // its continuation cell), wildcard skip [null, null, null, null], // fully wildcard row (4 columns wide) ], $buffer);
Rules the parser enforces (each violation raises immediately — older versions skipped malformed input and passed green):
- The outer grid must be a list, with exactly
$buffer->height()rows; a height or row-width mismatch fails the assertion, naming the dimensions. Ragged rows used to leave their tails never-compared — they are a hard error now. - A row is either a plain string (its clusters spread cell-by-cell; a wide cluster claims the following continuation cell) or a list of cells of exactly buffer width.
- A cell is
null(a deliberate wildcard for that one position), a string holding a single narrow grapheme cluster, or an array with keys fromruneandwidth(0continuation /1/2). Unknown keys throw with a teaching message — a typo'd'run'would once have unchecked the position forever. A string cell claiming more than one cluster throws: spread it across cells or use the array form. - A grid whose every cell is a wildcard fails (“compared zero cells”) — vacuous green is the exact failure mode this library exists to prevent.
- Failures dump up to 20 mismatching cells with
(row, col)coordinates, then a summary of cells compared vs skipped, so a full-buffer diff cannot flood a test log.
GoldenFile
use SugarCraft\Testing\Snapshot\GoldenFile; $actual = 'frame'; // the bytes you want pinned $path = GoldenFile::resolve(__DIR__, 'view.golden'); // anchors at <this file's dir>/fixtures/view.golden; // escapes ('../', backslash smuggles, symlinked // sub-directories) throw $expected = GoldenFile::load($path); // null ONLY when absent; unreadable or // symlinked golden -> RuntimeException if ($expected === null) { GoldenFile::save($path, $actual); // atomic (temp + rename), clobbering: seeding } // GoldenFile::create($path, $actual); // one-way creation: throws // GoldenFileExistsException if the golden // exists — the race-safe path behind // assertGoldenAnsi's create flag // GoldenFile::update($path, $actual); // deliberate re-baseline (same atomic write)
Every write goes through a uniquely named temp file and a rename(), so a
crash mid-write cannot leave a half-baseline; empty content (0 bytes) is a
legal golden for a silent renderer. Reading or writing through a symlinked
golden is refused outright — a fixture path must be a fixture.
Graphics stream assertions
SugarCraft\Testing\Graphics\* decodes the three inline-image protocols back
into structured images so tests can assert on what a renderer transmitted —
the parse-side mirror (inverse) of sugar-image's renderers (used by its
suite):
use SugarCraft\Testing\Graphics\{GraphicsAssertions, Iterm2Stream, KittyStream, Mode, SixelStream}; // Decode into an inspectable stream object with typed accessors. $kitty = KittyStream::decode($bytes)->image(); // png(), cols(), rows(), id()… $sixel = SixelStream::decode($bytes); // grid(): PixelGrid, palette() $iterm = Iterm2Stream::decode($bytes); // payload(), png(), params() $mode = Mode::detect($bytes); // which protocol it introduces // One-shot assertions over a raw stream: GraphicsAssertions::assertRendersTo($bytes, Mode::Kitty, $expectedGrid, tolerance: 0); GraphicsAssertions::assertInlineImageDimensions($bytes, Mode::Sixel, 64, 32); GraphicsAssertions::assertProtocolRoundTrip($bytes, Mode::Iterm2);
Pixel materialization goes through GD. Where ext-gd is absent the pixel paths
fail loudly (a RuntimeException naming the missing extension) — a
graphics assertion never skips silently, because a skipped pixel check reads
identical to a passing one in a green run. Decoding needs no GD.
These decoders eat captured output, which may be hostile-sized, so
DecodeLimits budgets every allocation before it happens: declared rasters
beyond 4M cells, numeric literals beyond 9 digits, o=z payloads inflating
past 64 MiB (capped while streaming, so a bomb cannot pre-materialize),
and images declaring more than 16.7M pixels all raise a catchable
MalformedGraphicsException with a precise reason — never an OOM-killed
runner. Malformed streams likewise raise MalformedGraphicsException (bad
parameter, missing introducer, unterminated payload…).
ScriptedInput
use SugarCraft\Testing\Input\ScriptedInput; $input = ScriptedInput::new() ->key('h', ctrl: true) // Ctrl+h ->arrow('down') // lowercase direction vocabulary ->enter() ->ticks(5) // 5 harness TickMsgs — see the tick note below ->resize(120, 40) ->key('q') ->build(); // list<Msg>, feed with $sim->send(...$input->build())
Builders: new(), key(char, shift:, ctrl:, alt:), namedKey(KeyType, rune, shift:, ctrl:, alt:), enter(), escape(), backspace(), tab(),
arrow(dir, shift:, ctrl:, alt:), ticks(count, seconds:, factory:),
resize(cols, rows), quit() (a real QuitMsg — it halts the simulator
drain, production-style), mouse(...), wheel(...), paste(content),
bytes(...$reads), clipboard(content, selection:),
keyboardEnhancements(flags), push(msg) for anything bespoke, plus
build(), count(), and constants READ_SIZE, PASTE_START, PASTE_END.
Ticks. Every fleet lib ships its own tick message class (minesweeper,
widgets, forms, … each match their own), so a bare ticks(5) enqueues the
harness’s SugarCraft\Testing\Input\TickMsg — which matches nothing in
your model. Pass the factory to script your lib’s real tick:
->ticks(3, factory: fn (int $i, float $s) => new \My\Lib\Msg\TickMsg($s))
Symbolic builders (key(), arrow(), enter(), …) produce the DECODED message
a keyboard press becomes — arrow('down') is a KeyType::Down message, not bytes.
bytes() carries the real WIRE form instead (arrow keys as ESC [ C). Never script
an arrow as the literal → glyph: a UTF-8 arrow is plain text — it takes the
paste/IME path and decodes as character input, never as KeyType::Down. (A PTY
harness driving a program from outside must send the ESC [ C bytes for the same
reason.)
Terminal-shaped input is decoded by sugar-core's real InputReader — the parser
Program reads stdin with — so a script sees the same messages the live runtime
delivers:
ScriptedInput::new() ->paste("line 1\nline 2") // PasteStartMsg, PasteEndMsg, PasteMsg (sanitized) ->bytes("\x1b[200~ab\x1b[20", "1~") // raw reads: an end marker split across two reads ->bytes("\x1b") // a lone ESC becomes Escape after the silence ->build(); ScriptedInput::new()->withSanitizePaste(false)->paste($raw); // mirrors ProgramOptions::$sanitizePaste
paste() follows the runtime: payloads are sanitized by default, a paste past
InputReader::MAX_PASTE_BYTES arrives as several PasteMsgs (concatenate every one
between PasteStartMsg and the one after PasteEndMsg), and a paste whose end marker
never arrives is closed out eagerly. For a bare PasteMsg with no envelope,
push(new PasteMsg($text)).
ProgramSimulator fires each subscription once after init() and once after every sent
message, delivering what it produces before the next sent message; run() replays from
the Program's model each time, so repeated runs give the same result.
TestResult
run() returns a SugarCraft\Testing\TestResult:
->model— final model object->view— lastview()string->cmds— every captured command closure, in dispatch order->output— concatenatedview()output across the run plusRawMsg/PrintMsgside-channel writes->frames— theview()after each delivered message (plus the final one), for animation/progression assertions->droppedAfterStop— messages left in the queue when aQuitMsg/InterruptMsghalted the drain (0 in a healthy run; a surprise non-zero means your script over-sent)
Verbs: assertCmdCount(int), assertCmdContains(callable $filter, ?string $message)
(failure names the captured-cmd count, since “matched none of 0” is the usual story),
assertNoCmds().
TapeRecorder
SugarCraft\Testing\Tape\TapeRecorder builds .tape scripts for the VHS
GIF renderer (it records nothing from a running program — you compose the
choreography):
use SugarCraft\Testing\Tape\TapeRecorder; TapeRecorder::to(__DIR__ . '/.vhs/demo.tape') ->header(theme: 'TokyoNight') // Set Theme/FontSize/Width/Height ->type('php examples/demo.php') ->enter() ->sleep(1) ->type("q") ->comment('rendered by .github/workflows/vhs.yml') ->save();
Everything interpolated is escaped so it cannot forge a standalone directive
line — type() neutralizes newlines and control bytes inside the quotes,
comment() prefixes every physical line, and even the theme string is escaped.
line() is the deliberate verbatim escape hatch: CI executes the tapes these
files describe, so only feed it trusted, generated directives. See
examples/tape-recorder.php for a runnable demo.
TemporaryDirectoryTrait
SugarCraft\Testing\Concerns\TemporaryDirectoryTrait gives a test class a
scratch directory that is per-test unique, mode-0700, and removed without
ever following a planted symlink into someone else's tree. The trait owns
the $tmpDir property — do not re-declare it in your class (that is a
fatal). Call $this->setUpTemporaryDirectory() from setUp() and
$this->tearDownTemporaryDirectory() from tearDown(); override
getTempDirSuffix() to name the directory after your test class.
LoopPin
If your suite arms a safety timer to bound a wait — addTimer(5.0, fn () => $loop->stop())
around some async work — pin the shared event loop in your tests/bootstrap.php:
\SugarCraft\Testing\LoopPin::pinStableClock();
Why: Loop::get() autodetects, and where ext-uv is installed it returns ExtUvLoop.
libuv computes a timer's deadline against the loop's cached clock, and refreshes that
cache once per loop iteration — as soon as the poll syscall returns. A timer's error is
the wall time between the last refresh and the
arm. A PHPUnit process runs the loop in short bursts with long stretches of synchronous
test code in between, so the effective delay becomes delay - idle_since_last_refresh. A
timer armed for 10s after 12s of idle is already overdue and fires on the first tick, so
your safety net fires instead of the work and the test fails having consumed no wall
time — an intermittent flake with no obvious cause.
The measurements — a 5s timer armed after N seconds of synchronous work at five different
arming sites, under ext-uv and under StreamSelectLoop — live in exactly one place: the
timer-accuracy notes on SugarCraft\Core\Program::run() in sugar-core. They are not
copied here, because a copy in three files is how one wrong row came to be wrong in three
files.
What the numbers show: every ext-uv figure is linear in the idle, which is the proof the
deadline comes off a stale clock rather than the OS clock. StreamSelectLoop returns the
full delay at every site and every N.
Do not read "armed before the loop's first run()" as an escape hatch. It survives
only in the degenerate case where the armed timer is the loop's earliest deadline and
nothing else wakes the first poll; the apparent safety is an arithmetic cancellation
inside UV::RUN_ONCE, not — as it is often stated — an absent baseline. A never-run loop
has a baseline (uv_loop_new() sets it), and uv_now() shows the cache stale by the full
pre-run idle at the moment of the arm. Add a periodic render tick, an earlier timer, or a
stream with bytes already waiting — a real Program always has at least one — and the
timer fires early by that whole idle, exactly like every other row. The rule is about the
handles present at run time, not about the moment of the arm.
Two corollaries worth spelling out, because they are easy to get backwards:
- It is not "the loop must stay inside
uv_run()". One$loop->run()is not oneuv_run()—ExtUvLoop::run()is itself awhileloop of shortuv_run()calls. Idle between refreshes is the hazard, wherever it comes from, and blocking inside a callback does it just as effectively as idling between bursts. StreamSelectLoopis not safe because it lacks a cached clock — it has one (Timers::$time, fromhrtime(true) * 1e-9). It is safe becauseTimers::add()callsTimers::updateTime()at arm time.
Program::run() is not automatically immune either; see the timer-accuracy notes on that
method for which sugar-core paths are safe (blocking in update(), because Cmds are
deferred through futureTick()) and which are exposed (blocking inside a Cmd that then
arms). Pin the loop before anything else touches it: an already-created loop may own
watchers that do not transfer. LoopPin additionally calls Loop::stop() after installing
the pin, which disarms the shutdown hook Loop::get() may already have registered — so a
late pin can no longer leave the process hanging on a stale loop with a periodic timer, and
there is no surprise loop run after your last test. That last part cuts both ways, and the
flag is process-wide and permanent: it also switches off React's "you never have to call
run() yourself" convenience, so a suite that schedules work on Loop::get() and leaves
the shutdown hook to execute it gets nothing instead. Call run() explicitly — which a
test that wants to assert on the outcome should be doing anyway.
Note one global side effect: StreamSelectLoop's constructor calls
pcntl_async_signals(true) process-wide, at bootstrap. Program::run() normally does the
same in production, so for most suites this is the production setting arriving earlier
rather than a divergence — but a Program constructed with withoutSignalHandler or
catchInterrupts: false never enables async signals at all, and a suite built around one
of those does diverge. Re-check it if your suite does its own pcntl_signal() bookkeeping.
LoopPin::hasStaleClockRisk($loop) reports whether a given loop is affected, for a suite
that wants to assert or diagnose rather than pin.
License
MIT
Credits & inspiration
Originally inspired by the Go Charm ecosystem
(upstream-antecedented by charmbracelet/x/exp/golden and charmbracelet/teatest);
SugarCraft is developed as a native PHP project.