Skip to content

Record UI interactions as replayable scripts, for demos and help pages - #2059

Merged
jcschaff merged 14 commits into
masterfrom
feat/ui-macro-recorder
Sep 4, 2026
Merged

Record UI interactions as replayable scripts, for demos and help pages#2059
jcschaff merged 14 commits into
masterfrom
feat/ui-macro-recorder

Conversation

@jcschaff

@jcschaff jcschaff commented Sep 4, 2026

Copy link
Copy Markdown
Member

The debug bridge was already a complete replay vocabulary — /click, /menu, /setText, /selectTreeRow — with no recorder attached. This adds the missing half, and then the pieces needed to turn a recording into documentation.

Everything is dev-only, behind the existing -Dvcell.debugBridge=true, so it ships inert.

What you can do now

bridge.sh record start recordings/my-feature.json
# ... drive the feature by hand ...
bridge.sh record stop

bridge.sh replay recordings/my-feature.json                       # semantic: fast, for CI
bridge.sh replay recordings/my-feature.json --driver robot        # cursor moves: filmable
bridge.sh replay recordings/my-feature.json --shots /tmp/shots --shot-scale 0.5
doc-scaffold.py recordings/my-feature.json --shots /tmp/shots --target MyFeature --out page.xml

Semantic capture, never coordinates. Each step names its target the way the bridge already resolves one — name= first because it survives layout changes, a node path next, a registry id last. Pixel positions are never stored.

One recording, two replay drivers. semantic fires buttons through doClick() and moves no cursor — right for CI. robot glides the pointer and clicks for real — the only mode worth filming, since a click with no pointer near it reads as broken. Same artifact either way.

Acceptance test

scenarios/detach-window-recorded.sh makes the same assertions as the hand-written detach-window.sh, but every user action comes from a capture. It passes 15/15 under both drivers, and the hand-written original still passes its 18. Steps replay one at a time (--from/--to) so state is checked between clicks — playing the whole script and asserting once at the end would pass even if detach and reattach both did nothing.

Help-page generation

doc-scaffold.py turns a recording plus its captures into a <vcelldoc> skeleton for UserDocumentation/originalXML. It writes only the mechanical parts — ordered steps, image references, matching target and title — and leaves prose to a person; auto-generated help text reads like auto-generated help text.

Verified end to end: a generated page compiles through DocumentCompiler and renders with its images inline.

The SpringSaLaD help pages this was first tested against are tracked separately in #2060 — that TOC change is not part of this PR.

Things this learned against the running client

Each of these presents as "the recorder is broken":

  • doClick() is invisible to the recorder — it calls listeners directly and posts no event, so a session driven with bridge.sh click records nothing. Hence rbclick (native press/release).
  • MOUSE_CLICKED is unusable for menus. It is synthesized after the release, and a click that dismisses the popup it landed in destroys its own component first, so it never arrives. Capture is on press/release.
  • Not everything in a menu bar is a menu. The detach toggle is a JMenuItem whose entire label is a tooltip, so /menu — which matches items by visible text — cannot address it. Text-less items fall back to a click by name.
  • A modal dialog produces zero events. On a source build the version-mismatch warning appears a moment after the menus do. record status reports rawEvents precisely to separate "saw nothing" from "captured nothing".
  • A screenshot taken too early silently documents the wrong screen. /idle drains the EDT, but VCell fills panels from background tasks — two different steps produced byte-identical images. Hence --shot-delay.
  • Detaching replaces a window, so identity-based "new window" detection fired on a window that never left the screen. Detection compares titles.

Durability

An index is not a durable way to name a thing, and it is not documentation either:

  • Rows and tabs record their displayed text alongside the index; replay resolves by text first. A biomodel holds zero or more applications of any type in any order, so a recorded row number is only true for the tree as it stood.
  • /tree rows report userType and applicationType, and /findRow takes appType, so "find the SpringSaLaD application" works in any model. The four in the example file report RULE_BASED_STOCHASTIC, SPRINGSALAD, NETWORK_DETERMINISTIC, NETWORK_STOCHASTIC.
  • /findRow searches the whole tree/table model — /tree caps its dump at 25 table / 100 tree rows, so a chooser in a 137-entry directory was previously undrivable.
  • Each step records how durable its selector is, turning naming debt into a list you can read off a fresh recording. That is how the SpringSaLaD panels got named: they had zero setName calls, and 3 of 4 recorded steps resolved only by path. Now all four resolve by name.

Crash safety

The script is flushed after every step, written via a .part file and renamed into place, so a kill -9 mid-session leaves a valid script of everything up to that point rather than nothing. Serializing happens on the EDT where the step list is consistent; the I/O goes to one background thread so a slow filesystem cannot stutter the UI being recorded. Verified by killing the client mid-recording.

Note for reviewers

The help build profile activates only when target/classes/vcellDoc is missing, so a plain rebuild silently skips it and doc changes appear to have no effect. Remove that directory or run DocumentCompiler directly.

🤖 Generated with Claude Code

https://claude.ai/code/session_019HAnpFxkzf9LmxBayDSANf

jcschaff and others added 14 commits September 3, 2026 21:46
Two inverses of what the bridge already does, both needed by a recorder.

bestSelector() answers "what selector names THIS component?" - the reverse of
findByPath(). Preference order is deliberate and is not "most specific": name=
first because it survives layout changes, then a node path because it at least
survives a restart, and a registry id last, since ids are stable only within a
session and a recording that leans on one replays today and resolves to nothing
tomorrow.

glide()/robotClick() drive a component the way a hand does. click() fires buttons
through doClick(), which moves no cursor and - because it calls its listeners
directly rather than posting an event - is invisible to anything watching the AWT
event queue. That is fine for a test and wrong for both a filmed replay and a
recording session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HAnpFxkzf9LmxBayDSANf
The bridge was already a complete replay vocabulary with no recorder attached.
UiRecorder is the missing half: a passive AWTEventListener that writes what a
person did as steps in that same vocabulary, so a recording replays through
endpoints that already exist.

Captured semantically, never as coordinates - pixel positions break on a
different screen or any layout change, which is the problem name= selectors were
introduced to solve.

Four things the shape of this had to account for:

- Menu picks record as menu "Help>VCell Properties ...", because the popup they
  happened in will not exist at replay. But an item with no text cannot be
  addressed that way at all: VCell puts icon-only controls straight into the menu
  bar - the detach toggle is a JMenuItem whose entire label is a tooltip - so
  those fall back to a click on their name.
- Capture is on press/release, not MOUSE_CLICKED. CLICKED is synthesized after
  the release, and a click that dismisses the popup it landed in destroys its own
  component first, so for every menu pick it never arrives.
- A JPasswordField is never captured. Account>Login is on the happy path of most
  tutorials, and a faithful keystroke recorder would write the user's password
  into a file they are about to commit.
- A step that opened a window records it, so replay can wait rather than sleep.
  Detection compares window titles, not identities: detaching a child window
  swaps an owned dialog for an un-owned frame, so a new Window object appears
  carrying a title that never left the screen.

/record status reports rawEvents alongside the step count, which is what
separates "captured nothing" from "saw nothing" - a modal dialog blocking input
produces the second, and looks identical from outside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HAnpFxkzf9LmxBayDSANf
…eal cursor

Replay issues each step through the endpoint that already implements that verb,
so it adds no new way to drive the UI.

One recording, two drivers, chosen at playback. semantic is fast and moves no
cursor - right for CI. robot glides the pointer to each target and clicks for
real, which is the only mode worth filming: a click with no pointer anywhere near
it reads as a broken video.

It is also patient in the two places a fixed sleep is not. It retries a step
until it takes, because a target is routinely not ready the instant the previous
one finishes - a menu item sits disabled for a moment after a modal dialog is
dismissed, which is the same flake detach-window.sh works around with a
ten-attempt loop of its own. Retrying is safe precisely because both failure
signals mean nothing happened: an error reply, or a false result meaning the
selector did not resolve. And where a step opened a window, it waits for that
window instead of guessing how long the machine will take.

--from/--to play a slice, which is what lets a scenario assert between steps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HAnpFxkzf9LmxBayDSANf
The acceptance test for the recorder: identical assertions to detach-window.sh,
but every user action comes from a capture of somebody opening the menu and
clicking the toggle twice. If a recording can stand in for a hand-written
scenario without weakening it, the recorder is capturing the right things at the
right level of abstraction. It passes 15/15 under both drivers, and the
hand-written original still passes its 18.

It also fixes the division of labour in place. The recording holds the
navigation - what was clicked, in what order, with what timing. The scenario
holds the assertions, which no recorder can infer, plus the window moves and
minimize requests that are test scaffolding rather than things a user did.

Steps are replayed one at a time so state is checked between clicks. Playing the
whole script and asserting once at the end would pass even if detach and reattach
both silently did nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HAnpFxkzf9LmxBayDSANf
Endpoint reference next to the code, workflow in the skill, per the split those
already follow.

The three failure modes are worth writing down because each one presents as "the
recorder is broken": driving a session with bridge.sh click records nothing (it
fires doClick(), which never reaches the event queue); a modal dialog blocking
input produces no events at all, and on a source build the version-mismatch
warning appears a moment AFTER the menus do; and not everything in a menu bar is
a menu, so /menu cannot address VCell's icon-only controls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HAnpFxkzf9LmxBayDSANf
Steps used to accumulate in memory and reach the file only on stop, so killing
the client - or a crash - lost the whole take. That is worst for exactly the
recording nobody wants to redo: the long one.

start() now fixes the destination and writes immediately, so the file exists and
parses even if nothing is ever captured; every step rewrites it. A kill -9 partway
through now leaves a valid script of everything up to that point.

Written via a .part file and renamed into place. A crash during a plain write
would leave a half-written file, which is worse than no file at all - the
recording would look present and fail to parse. Serializing happens on the EDT
where the step list is consistent; the I/O goes to one background thread, so a
slow or networked filesystem cannot stutter the UI being recorded, and one thread
keeps the writes ordered.

stop(file) still names the final destination, and moves the auto-named working
file there rather than leaving it in the scratch directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HAnpFxkzf9LmxBayDSANf
Four finished help pages - "Introduction to SpringSaLaD Modeling", "SpringSaLaD
Application", and the Species/Reactions specification pages - existed in the
source but were unreachable from the help TOC, and DocumentCompiler reported them
as unreferenced on every build.

They were commented out deliberately, by the same 2024 commit that authored two
of them, which reads as gating content ahead of the feature's release rather than
an oversight. SpringSaLaD has shipped since. Worth confirming with the author if
any of the content is still considered provisional.

Verified by running DocumentCompiler: the four pages render, and their
unreferenced errors are gone. WarningsList and SimResultsDataRange are still
orphaned, and the four oversized images still fail the 500KB check - both
pre-existing and untouched here.

Note for anyone rebuilding: the help profile activates only when
target/classes/vcellDoc is MISSING, so a plain rebuild silently skips it and doc
changes appear to have no effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HAnpFxkzf9LmxBayDSANf
A recorded UI script addresses a component by name where it can, and falls back
to a positional path where it cannot - and a path breaks the next time that panel
is rearranged. These panels had no names at all: SpringSaladViewerPanel,
SpringSaladSpeciesPanel and their canvas and legend had zero setName calls
between them, so every recording of the SpringSaLaD viewer would have been
fragile exactly where we most want to record one.

Adds 14 names across the two SpringSaLaD panels, and one in ApplicationSubPanel
that gives every application sub-panel's tabbed pane a distinct name derived from
its concrete subclass. The species checkboxes are named per species and per site
type, since the row label is a separate JLabel and the checkbox itself carries no
text at all.

Measured effect on a real recording of the SpringSaLaD application: before, three
of four steps resolved only by node path; after, all four resolve by name.

All inert at runtime - setName affects nothing but addressability.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HAnpFxkzf9LmxBayDSANf
Three changes, all the same lesson: an index is not a durable way to name a
thing, and it is not documentation either.

- Rows and tabs are recorded with their displayed text (rowText, tabTitle)
  alongside the index. "Select row 10" is not a help page, and it is not even
  reliable: a biomodel holds zero or more applications of any type in any order,
  so that index is only true for the tree as it stood when recorded.
- /findRow returns a row number by what a row displays, searching the whole
  model. /tree caps its dump at 25 table rows and 100 tree rows and reports
  truncated, so anything below that was simply unreachable - a file chooser in a
  137-entry directory could not be driven at all.
- /robotClick takes a row, which also means a tab. /selectTreeRow and /selectTab
  act through the model and post no input event, so they are invisible to the
  recorder; without this a scripted recording could not capture tree navigation,
  which is how most of VCell is reached.

Also: each step now records how durable its selector is, so naming debt reads off
a fresh recording instead of surfacing when a script breaks. And describe() walks
up to the first superclass with a non-empty simple name - VCell builds plenty of
anonymous subclasses, and their getSimpleName() is "", which left notes blank.

/screenshot gained scale, name and dir. Scaling belongs in Java because the help
system rejects images over 500KB and a full-size window capture exceeds it;
doing it here keeps documentation capture free of platform image tools.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HAnpFxkzf9LmxBayDSANf
Replay can now photograph what it does (--shots), and doc-scaffold.py turns a
recording plus its captures into a <vcelldoc> page skeleton for
UserDocumentation/originalXML.

The split is deliberate: the scaffold writes only the mechanical parts - ordered
steps, image references, a matching target and title - and leaves the prose to a
person. Auto-generated help text reads like auto-generated help text; the
recorder knows what was clicked, not why it matters to a user. The existing
format already separates the two, so this fits it rather than fighting it.

Replay also resolves a row by its recorded text before falling back to the index,
via /findRow. Row order is not stable across models, and expanding a node above
one shifts everything below it.

Three things this learned against the real client:

- --shot-delay exists because /idle is not enough. It drains the EDT, but VCell
  fills many panels from background tasks, so an idle EDT does not mean the
  pixels are final. Captured too early, two different steps produced
  byte-identical images - a page that silently documents the wrong screen.
- --shot-scale exists because DocumentCompiler rejects images over 500KB. At 0.5
  a full window lands near 30KB; at full size it would fail the doc build.
- The included recording is of the SpringSaLaD application in
  exampleModels/SpringSalad_SolverSuite.vcml, identified by its Specifications
  tabs (Species / Reaction / Molecular Structures) rather than by position -
  application type is a property of the model, not of the ordering, and the first
  application in this file is an NFSim one.

Verified end to end: the generated page compiles through DocumentCompiler and
renders with its images inline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HAnpFxkzf9LmxBayDSANf
VCell's trees carry domain objects, and the label often does not identify them.
A biomodel holds zero or more applications of any type in any order, named
whatever the author chose - this example model has Application0, Application2 and
two "Copy of Application0" - and on screen only the row's icon says which is
NFSim, SpringSaLaD, deterministic or stochastic. So a caller had nothing durable
to select on, and my first attempt guessed from which Specifications tabs
appeared, which is inference where the model has the answer outright.

/tree rows now carry userType and, where the object has one, applicationType.
/findRow takes appType, so "find the SpringSaLaD application" works in any model
regardless of naming or order. Verified against the example model: the four
applications report RULE_BASED_STOCHASTIC, SPRINGSALAD, NETWORK_DETERMINISTIC
and NETWORK_STOCHASTIC, and appType=SPRINGSALAD selects row 10.

Read reflectively rather than by importing SimulationContext: this is a dev-only
introspection class that otherwise needs nothing beyond the JDK, and anything
exposing a no-argument getApplicationType() is reported the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HAnpFxkzf9LmxBayDSANf
…ontext

Was a reflective getApplicationType() lookup, to keep this dev-only class free of
domain imports. Not worth it: SwingInspector lives in vcell-client, which already
depends on the biology model, so naming the type costs nothing the reflection was
saving - and it buys compiler checking, so renaming the accessor or the enum
breaks the build here instead of silently emptying a field at runtime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HAnpFxkzf9LmxBayDSANf
…ntents"

This reverts commit 66ab38d. The TOC change is unrelated to the recorder work
and needs its own review by whoever gated that content, so it moves to an issue
rather than riding along in this PR. Everything the change and its verification
established is written up there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HAnpFxkzf9LmxBayDSANf
A session driven entirely by bridge.sh recorded NOTHING, because the AWT listener
only sees input that reaches the event queue and the model-based endpoints post
none: doClick() calls its listeners directly, setSelectedIndex changes a model.
That was a footgun which looked like a broken recorder every time, and the
documented workaround - drive with rbclick instead - forced real cursor movement
on sessions that had no reason to want it.

So those endpoints now record the step directly. This is not a patch over the
missing event, it is better information: the listener has to INFER a step from a
coordinate - which component was under the pointer, which row that pixel falls in
- whereas an endpoint already knows the verb, the target and the argument
exactly.

Wired into click (the AbstractButton branch only), menu, setText, selectTab,
selectTreeRow, selectTableRow and expandTreeRow. Robot-driven helpers
deliberately do NOT report themselves: the listener already sees their real
events, so doing both would record every step twice. Verified - a mixed session
of one /menu, one rbclick and one /click produces exactly three steps.

expandTreeRow becomes a recordable and replayable verb in the process, which
closes the gap where a recording could select a tree node but never reach one
that needed expanding first.

The one thing still invisible is a doClick() from application code, and that is
correct: it is the program acting, not the user.

Scripted setup performed while recording is now captured too, so
record start takes captureBridgeActions=false (--no-bridge-actions) for sessions
where bridge calls are only setting the stage.

Both scenarios still pass unchanged: recorded 15/15, hand-written 18/18, smoke OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019HAnpFxkzf9LmxBayDSANf
@jcschaff
jcschaff merged commit ad7ec61 into master Sep 4, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant