9e37213cd4
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
221 lines
7.3 KiB
Python
Executable File
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()
|