Record UI interactions as replayable scripts, for demos and help pages - #2059
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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.
semanticfires buttons throughdoClick()and moves no cursor — right for CI.robotglides 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.shmakes the same assertions as the hand-writtendetach-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.pyturns a recording plus its captures into a<vcelldoc>skeleton forUserDocumentation/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
DocumentCompilerand 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 withbridge.sh clickrecords nothing. Hencerbclick(native press/release).MOUSE_CLICKEDis 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.JMenuItemwhose 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.record statusreportsrawEventsprecisely to separate "saw nothing" from "captured nothing"./idledrains the EDT, but VCell fills panels from background tasks — two different steps produced byte-identical images. Hence--shot-delay.Durability
An index is not a durable way to name a thing, and it is not documentation either:
/treerows reportuserTypeandapplicationType, and/findRowtakesappType, so "find the SpringSaLaD application" works in any model. The four in the example file reportRULE_BASED_STOCHASTIC,SPRINGSALAD,NETWORK_DETERMINISTIC,NETWORK_STOCHASTIC./findRowsearches the whole tree/table model —/treecaps its dump at 25 table / 100 tree rows, so a chooser in a 137-entry directory was previously undrivable.setNamecalls, 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
.partfile and renamed into place, so akill -9mid-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/vcellDocis missing, so a plain rebuild silently skips it and doc changes appear to have no effect. Remove that directory or runDocumentCompilerdirectly.🤖 Generated with Claude Code
https://claude.ai/code/session_019HAnpFxkzf9LmxBayDSANf