Skip to content

feat(pace): command pacing for recorded runs - #316

Merged
kevinccbsg merged 6 commits into
mainfrom
feat/command-pacing
Jul 28, 2026
Merged

feat(pace): command pacing for recorded runs#316
kevinccbsg merged 6 commits into
mainfrom
feat/command-pacing

Conversation

@kevinccbsg

Copy link
Copy Markdown
Member

Lets a recorded twd-cli run space out its own commands, so the video is watchable instead of a blur.

This is the twd-js half. It does nothing on its own until twd-cli drives it, which is a separate PR in that repo.

Why not just slow the video down

twd-cli already has record.speed, an ffmpeg setpts filter applied after recording. It stretches the same frames over a longer timeline, so the effective frame rate falls in proportion. Measured on identical page activity:

speed duration effective fps
1 1.00s 30
0.5 1.90s 15.3
0.25 3.90s 7.7

It also slows the dead air exactly as much as the interesting moments. TWD owns its own in-page command loop, so it can pace the execution instead and keep full frame rate.

How it is enabled

window.__twdSetPace(ms), registered alongside the existing __TWD_STATE__ and __testRunner globals.

Deliberately not a public twd.setPace(). A documented API method can be left behind in a spec file, and every future CI run would then be slower with no obvious cause. There is a test asserting it never leaks out of src/index.ts or onto the twd object.

One number, two scales

The caller passes one number. The keystroke delay is derived from it and clamped at 60ms, because user-event applies delay uniformly and reusing the pace itself would put half a second between every character.

setPace(ms) between actions per keystroke
200 200ms 20ms
500 500ms 50ms
2000 2000ms 60ms (clamped)

What is paced, and what is not

Paced: userEvent.* interactions and twd.visit. Both are already async, so nothing became async that was not.

Not paced, on purpose: should() and twd.setInputValue() are synchronous chainables and pacing them would be a breaking change; the screenDom proxy is a pass-through with no uniform hook; queries do not change the page, so a pause after one is dead air with no highlight to show.

Keystroke spacing

user-event applies delay at config level, in wrapAndBindImpl after every API method as well as between keystrokes, pointer actions and option selections. So when paced the proxy routes through a cached setup({ delay }) instance rather than merging options per call. That covers every method uniformly and sidesteps clear(element) being the one direct API method that takes no options.

The cache is keyed on a pace generation counter, not on the delay, so setting the same value twice still invalidates.

Behavior note: the direct API builds a fresh System per call while a setup instance shares pointer state across calls. That is the library's recommended usage and arguably more realistic, and it only applies when pacing is on, which only happens inside a recorded run.

Normal runs are untouched

pace() returns undefined synchronously when disabled: no promise allocated, no timer scheduled, no setup instance built. There is a test pinning that it returns undefined and not a Promise, which fails loudly if anyone later makes it an async function.

Verification

439 tests passing, up from 406. tsc unchanged at its 4 pre-existing errors, none in the touched files.

Driven end to end against examples/twd-test-app with Puppeteer:

window.__twdSetPace is a function
pace 0   -> applied=0    run took 120ms
pace 600 -> applied=600  run took 718ms   (6.0x slower)

120 + 600 = 720 against a measured 718, so the hold lands exactly once per action and setPace(0) restores full speed. Manually confirmed at 200 and 500, which both read well.

Included docs

specs/2026-07-28-twd-js-command-pacing-design.md is the design and specs/2026-07-28-twd-js-command-pacing-plan.md is the plan. The earlier overlay draft is kept but marked superseded: overlays are dropped for v1, since the only reason to pause after a query is to highlight what it found.

No public documentation is added, because __twdSetPace is deliberately not public API.

Next

twd-cli gains a record.pace key and a --record-pace <ms> flag that calls this. That needs a twd-js release first, since the global does not exist until then.

kevinccbsg and others added 6 commits July 27, 2026 23:11
Splits the twd-js half out of the earlier combined recording spec, which
is superseded by twd-cli's video capture design.

Records the constraints found while checking feasibility so they do not
have to be rediscovered: should() is a synchronous chainable and cannot
await a pace delay without a breaking change, log() is synchronous,
overlays cannot be plain <body> children because of the
`body > div:not(#twd-sidebar-root)` scoping, runner events are not
awaited, and onFail fires after afterEach.

Direction only. Blocked on twd-cli video capture shipping first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pacing is what makes a recorded run watchable. twd-cli's record.speed is an
ffmpeg setpts filter applied after the fact: measured, it stretches the timeline
without adding frames, so 0.25 speed gives a 7.7fps video and slows the dead air
as much as the interesting moments. TWD owns the in-page command loop, so it can
space execution itself at full frame rate.

Design: a src/pace.ts module holding one number, reachable only through a
window.__twdSetPace tooling global rather than public API, so a stray call
cannot slow CI. pace() is added only where the code is already async, so no
public API becomes asynchronous and log() stays synchronous. Typing delay is
derived from the same number and applied through a cached user-event setup
instance, since user-event applies delay at config level after every API method.

Overlays are dropped for v1 and the earlier draft is marked superseded, keeping
its overlay research and correcting two things it got wrong.
@kevinccbsg
kevinccbsg merged commit 3aeceb4 into main Jul 28, 2026
9 checks passed
@kevinccbsg
kevinccbsg deleted the feat/command-pacing branch July 28, 2026 18:40
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