Files
improvise/docs/record-casts.py
T
Edward Langley 9e37213cd4 fix(docs): re-record the four casts from a script, with no shell to leak
improvise-2oxt. All four casts under docs/casts/ opened and closed on
`(0) Mac:<hostname> ~gf/u/edwlan/improvise` — a real hostname and home directory
on what improvise-kh8 made the site root — and `formulas.cast` and `import.cast`
showed that prompt as their visible OPENING frame. drill.cast also predated the
records columns entirely, showing a single `Value` column where the current
build draws a column per measure plus `+cat` and `+`.

The repair is structural, not careful. `docs/record-casts.py` drives `improvise`
on a pty it owns and writes asciicast v2 itself, so `improvise` is the pty's ONLY
child and there is no shell for a prompt to leak from — nothing to remember to
clean, and nothing that comes back when somebody re-records on another machine.
Three other things stopped being accidents with it: the geometry is an input
(120x37, the numbers docs/index.html already spelled, so they did not change),
the pacing is a per-step table, and the header carries no `env` (which held
$SHELL) and no `timestamp` (the recording machine's clock).

Two decisions that each cost a re-record. No `:q` is typed at the end: the player
loops, so the last frame is also the frame before the first, and a minibuffer
reading `:q` is what it would show — the process is signalled instead. And each
scenario had to be ARRANGED so the thing it names is visible: the drill cast
drilled into a cell with no records behind it, and the pivot cast pressed `c` on
a category already on the column axis. Both recorded cleanly and demonstrated
nothing — true frames about the wrong thing — and neither was visible from the
keystroke list.

That is the method worth keeping: a cast can be replayed through a small ANSI
reconstruction and READ AS TEXT, so "what does frame N show" has an answer rather
than a guess. It is how the `+cat`/`+` columns in the new drill.cast were
confirmed present, and how both dud scenarios were caught.

The guard is `landing.spec.js`, asserted over the cast FILES: no header carries
`env` or `timestamp`, the geometry is the one the page configures, and no chunk
contains an OSC window-title sequence — the leak's signature, emitted by a shell
once per prompt with the cwd in it and by `improvise` never. Asserted over the
file rather than the rendering because a player draws a prompt as ordinary text,
so a screenshot assertion would have to know what the hostname had been. BOTH
clauses were injected separately, because the `env` one would otherwise mask the
OSC one: the old cast fails on `env`, and the old cast with `env` stripped fails
on the title sequence.

docs/index.html loses the `p.dated` note admitting the drill cast was out of
date, which is now false.

Tests: 33 harness + 3 skipped (was 28 + 3). Rust and node unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 20:52:24 -07:00

221 lines
7.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Record the four asciicasts under docs/casts/ from a driven improvise session.
Not `asciinema rec`: that records a SHELL, and the shell it records is the
recorder's own — every previous cast opens and closes on a real hostname and
home-directory path on what is now the site root (improvise-2oxt). Here there is
no shell at all. `improvise` is the pty's only child, so there is nothing for a
prompt to leak into, and the geometry, the pacing and the keys are inputs rather
than whatever the recording machine happened to have.
Usage: python3 docs/record-casts.py [--out docs/casts] [--bin target/release/improvise]
The output is asciicast v2, which is what `asciinema-player` on docs/index.html
reads: one JSON header line, then `[time, "o", data]` per chunk.
"""
import argparse
import fcntl
import json
import os
import pty
import select
import signal
import struct
import subprocess
import sys
import termios
import time
COLS, ROWS = 120, 37
# Long enough that a reader can follow, short enough that a loop is bearable.
BEAT = 0.9
SETTLE = 0.35
def keys(*items):
"""A step list from (text, pause) pairs, with the pause defaulted."""
return [(k, BEAT) if isinstance(k, str) else k for k in items]
ESC = "\x1b"
def scenarios(root):
"""The four the landing page embeds, each named for the section over it."""
demo = os.path.join(root, "examples/demo.improv")
csv = os.path.join(root, "examples/demo.csv")
return {
# "Reassign an axis": the tile bar, a category moved, the grid redrawn.
"pivot": (
[demo],
keys(
("", 1.6),
("T", 1.2),
*[("\x1b[C", 0.35)] * 5,
("c", 2.2),
("r", 2.2),
(ESC, 1.6),
),
),
# "Drill into an aggregate": the records grid behind one cell, which is
# the cast 2oxt says is materially wrong — it predates the +cat and +
# columns entirely.
"drill": (
[demo],
keys(
("", 1.6),
# Customer off the page axis first, so a cell aggregates over
# all nine of them and the drill has more than one record to
# show. Drilling the shipped layout shows exactly one row,
# which is true and demonstrates nothing.
("T", 1.0),
*[("\x1b[C", 0.35)] * 3,
(" ", 1.8),
(ESC, 1.2),
# Gadgets/East at Cost 2025-01, which has records behind it.
# The origin cell is blank, and a drill into nothing draws an
# empty records grid — a true frame about the wrong thing.
("j", 1.0),
(">", 2.2),
*[("\x1b[C", 0.45)] * 6,
("", 1.4),
(ESC, 1.6),
),
),
# "Formulas over dimensions": the panel, then the formula that is
# already in the document, read as text rather than as a cell address.
"formulas": (
[demo],
keys(
("", 1.6),
# The panel first: the formula is TEXT over categories and
# items, which is the claim the section makes.
("F", 2.4),
(ESC, 1.0),
# Then move a category and let the Profit column follow. A
# formula naming `B3` could not survive this, which is the
# whole point and is only visible if the grid really moves.
("T", 1.0),
*[("\x1b[C", 0.35)] * 7,
("r", 2.6),
(ESC, 2.0),
),
),
# "Import a CSV": the wizard's own steps, stepped through.
"import": (
["import", csv, "-o", "/dev/null"],
keys(
("", 1.4),
("\r", 1.2),
("\r", 1.2),
("\r", 1.2),
("\r", 1.2),
("\r", 1.4),
(ESC, 1.0),
),
),
}
def record(binary, argv, steps, cwd):
"""Drive one session on a pty and return its asciicast event list."""
master, slave = pty.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS, 0, 0))
env = {
"TERM": "xterm-256color",
"PATH": "/usr/bin:/bin",
"LANG": "en_US.UTF-8",
"HOME": "/tmp",
# `improvise` is the only child, so nothing else can print a path.
"COLUMNS": str(COLS),
"LINES": str(ROWS),
}
child = subprocess.Popen(
[binary, *argv],
stdin=slave,
stdout=slave,
stderr=slave,
env=env,
cwd=cwd,
close_fds=True,
)
os.close(slave)
started = time.time()
events = []
def drain(until):
"""Everything the child says before `until`, timestamped as it arrives."""
while True:
budget = until - time.time()
if budget <= 0:
return
ready, _, _ = select.select([master], [], [], min(budget, 0.05))
if not ready:
continue
try:
chunk = os.read(master, 65536)
except OSError:
return
if not chunk:
return
events.append(
[round(time.time() - started, 6), "o", chunk.decode("utf-8", "replace")]
)
for text, pause in steps:
if text:
os.write(master, text.encode())
drain(time.time() + pause)
# No `:q` typed at the end: the player loops, so the last frame is also the
# frame after the last one, and a minibuffer reading `:q` is what it would
# show. The process is signalled instead and the recording ends on the UI.
drain(time.time() + SETTLE)
if child.poll() is None:
child.send_signal(signal.SIGTERM)
try:
child.wait(timeout=3)
except subprocess.TimeoutExpired:
child.kill()
os.close(master)
return events
def main():
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ap = argparse.ArgumentParser()
ap.add_argument("--out", default=os.path.join(root, "docs/casts"))
ap.add_argument("--bin", default=os.path.join(root, "target/release/improvise"))
ap.add_argument("--only", default=None)
args = ap.parse_args()
if not os.path.exists(args.bin):
sys.exit(f"no binary at {args.bin} — cargo build --release --bin improvise")
for name, (argv, steps) in scenarios(root).items():
if args.only and name != args.only:
continue
events = record(args.bin, argv, steps, root)
header = {
"version": 2,
"width": COLS,
"height": ROWS,
# No `timestamp` and no `env`: the first carries the recording
# machine's clock and the second carried $SHELL (improvise-2oxt).
"idle_time_limit": 2.0,
"title": f"improvise — {name}",
}
path = os.path.join(args.out, f"{name}.cast")
with open(path, "w") as out:
out.write(json.dumps(header) + "\n")
for event in events:
out.write(json.dumps(event) + "\n")
print(f"{path}: {len(events)} chunks, {events[-1][0] if events else 0:.1f}s")
if __name__ == "__main__":
main()