fix(type): use insertText for framework-compatible input typing#13
fix(type): use insertText for framework-compatible input typing#13arielconti10 wants to merge 60 commits into
Conversation
Switch selector-targeted type steps to CDP Input.insertText so React and
other framework-controlled inputs receive proper input/change events.
Fix resolveTarget receiving StepType.text as an element query by
destructuring only { selector, within } before lookup.
Add tests for both typeText method paths and resolveTarget field exclusion.
|
@arielconti10 is attempting to deploy a commit to the Vercel Labs Team on Vercel. A member of the Team first needs to authorize it. |
- Skip waitForNextTick when charDelay is 0 to prevent deadlock between typing loop and capture loop - Add double-rAF sync before captureScreenshot to ensure fresh compositor frames during client-side navigation - Make chrome.kill() async with SIGKILL fallback after 3s timeout - Add SIGINT/SIGTERM handlers to non-watch record command Fixes vercel-labs#11
Register exit listener before sending SIGKILL and check proc.exitCode to handle the race where Chrome exits between the SIGTERM timeout and the SIGKILL, which would cause the exit event to never fire.
|
Thanks for this excellent contribution! The root cause analysis, before/after videos, and test coverage are all really well done. The One thing to fix before merging: the two empty Everything else looks good — build, type-check, and all 215 tests pass cleanly. |
Thanks! It's my pleasure to contribute to this awesome project! I've added the comments! |
|
Looking forward to this being released. My Claude wrote a workaround directly in the |
|
Same, excited for this to be released! |
|
+1 — this also fixes typing in headless recording ( |
Typing (actions.ts): - iterate text by code point so surrogate pairs (emoji) are not split - batch insertText into a single call when charDelay is 0 - no-op on empty text; extract dispatchCharKeyEvents helper - new optional "method" field on type steps (insertText or dispatchKeyEvent) with config validation, JSON schema, and docs; defaults to insertText when a selector is set Process lifecycle (new process.ts): - hasExited checks signalCode as well as exitCode, so signal-killed processes are detected - killProcess returns immediately for already-exited processes, clears its escalation timer, and awaits the same exit promise after SIGKILL; chrome.kill no longer stalls 3s or holds the event loop open Capture loop (recorder.ts): - remove the double requestAnimationFrame sync: it halved capture throughput, provided no measurable freshness benefit, and its Runtime.evaluate can hang indefinitely across navigations - release timeline tick waiters in a finally so a crashed loop cannot strand typeText pacing - memoize stop() so the runner finally block and interrupt cleanup share one shutdown; guard start() against a stop() that arrives while ensureFfmpeg is in flight Signal handling (new signals.ts): - shared interrupt-cleanup registry; runner registers recorder stop, temp video removal, client close, and chrome kill - record (watch and non-watch) and preview install the same handlers; SIGTERM now handled everywhere with exit codes 130/143 - watch mode waits for an in-flight recording without being cut off by the 10s force-exit timer, which now only bounds cleanup Verified end to end: SIGINT and SIGTERM mid-recording leave no orphaned Chrome or ffmpeg processes, temp videos, or user-data dirs; watch-mode SIGINT during a 15s recording finishes the video and exits 0.
Extends the WebReel DSL with an `upload` step that sets file inputs
using the CDP `DOM.setFileInputFiles` method — the same mechanism
Playwright uses internally to bypass browser security on file inputs.
DSL:
{ "action": "upload", "selector": "input[type='file']", "filePath": "audio.mp3" }
- `packages/@webreel/core/src/types.ts`: add getDocument, querySelector,
setFileInputFiles to CDPClient DOM namespace
- `packages/webreel/src/lib/types.ts`: add StepUpload interface + union member
- `packages/webreel/src/lib/config.ts`: add upload to VALID_ACTIONS,
KNOWN_STEP_KEYS, and validateStep switch
- `packages/webreel/src/lib/runner.ts`: add case "upload" to formatStep
and runVideo with existsSync guard before CDP call
- Tests: 7 new tests across config.test.ts and runner.test.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Port the safe performance parts of PR 12 by sld0Ant: - Producer/consumer prefetch queue in compositeFrames so sharp overlay rendering overlaps with ffmpeg encoding instead of alternating with it. The ffmpeg close/error listeners are now registered before streaming begins, and stdin EPIPE is tolerated when ffmpeg exits early. - Single-pass GIF output: overlay, palettegen, and paletteuse run in one ffmpeg filter_complex, replacing the intermediate x264 encode plus finalizeGif second pass. PR reports about 21 percent less time and 64 percent smaller GIFs. - Whole-pixel cursor cache key rounding so float jitter during cursor dwell no longer defeats the overlay cache. - scripts/benchmark.sh for repeatable record timing runs, with a small fix so the exit code check is not dead under set -e. - Ignore .idea/, videos/, and webreel.config.json. Also port the EXDEV cross-filesystem rename fallback (moveFileSync in @webreel/core with copy plus delete fallback, used by finalizeMp4, finalizeWebm, and the runner raw-video move). PR 12 references it, but it landed on main as commit 5cb748d (PR 15); ported here because this branch predates that merge. Excluded from the port: all recorder.ts changes (the HeadlessExperimental.beginFrame capture crashes chrome-headless-shell with SIGTRAP on this machine, and the local recorder rework must stay), the runner.ts frame pump, and the HeadlessExperimental CDP typings.
Port of vercel-labs#26 onto the fix/type-inserttext branch. Ported: - core autozoom.ts: crop computation, session grouping, spatial sub-grouping, size harmonization, keyframe generation, and ffmpeg zoompan expression with smoothstep easing, plus its test suite - core revealObserver.ts: in-page MutationObserver that unions click-revealed UI (menus, modals) into the zoom target - compositor: ComposeOptions.zoomFilter, three-stage pipeline (cursor overlay, zoompan, HUD overlay), EPIPE-tolerant stdin writer, and the HUD viewBox width clamp - runner: per-step zoom event capture with holdUntilMs for typing spans, reveal collection after postDelay, zoomEvents persisted in the timeline JSON, zoom filter applied during compositing - composite command: rebuilds the zoom filter from persisted zoomEvents so re-compositing preserves autozoom - config: autoZoom on VideoConfig, allowlisted in KNOWN_VIDEO_KEYS - docs: configuration page, JSON schema, SKILL.md, examples.md, both READMEs, and the examples/autozoom demo project Skipped: - the cursor pacing commit (tickDuplicate path advance, doubled moveDuration, waitForPathComplete wait swap), evaluated separately - the package rename to @lgariv/* and its release churn; imports in ported hunks were restored to @webreel/core Added beyond the PR: - shape validation for autoZoom tunables with config tests - minimal waitForPathComplete API on InteractionTimeline, with releaseWaiters extended to flush path-complete waiters so an interrupt mid-cursor-move cannot hang, plus timeline tests Credit: Lavie (lgariv) for the original PR.
Theme values (hudBg, hudColor, hudFontFamily) were spliced raw into the
JS source string evaluated in the recorded page. A value containing a
quote, backtick, or ${} could break or inject into the generated
script. Embed strings via JSON.stringify and coerce numeric fields
with Number(), matching the escAttr pattern already used in
compositor.ts. Also constrain hudPosition to the "top"/"bottom"
whitelist.
Claude-Session: https://claude.ai/code/session_01Pv8tMQt9GoVpfnpVcdwFxe
Register listeners for the chrome-remote-interface client's 'error' and 'disconnect' events so a Chrome crash or socket drop mid-run surfaces as a reported failure through the existing cleanup path instead of an unhandled EventEmitter 'error' that kills the process outside cleanup (orphaned Chrome, undeleted temp profile) or a silently stranded in-flight await.
runVideo and the composite command each re-implemented the zoom-filter/compose/thumbnail sequence, and the two copies had already drifted (runner.ts had verbose per-event logging the command lacked). Move normalizeAutoZoom and extractThumbnailIfConfigured into a new lib/compositing.ts alongside a new compositeRecording() helper, and have runVideo call it. Both names are re-exported from runner.ts for compatibility with existing importers.
Replace composite.ts's duplicated zoom-filter/compose/thumbnail block with a call to the shared compositeRecording() helper, using the persisted timeline zoomEvents and verbose: false (matching prior behavior, which had no per-event logging).
…itor finalizeGif (raw-recording GIF path) and buildGifConfig (overlay-composited GIF path) built separate filter graphs with different quality settings: only the compositor path used palettegen=stats_mode=full and bayer dithering. Extract the shared graph into buildGifFilter(width, fps?) in media.ts and use it from both call sites, so GIF quality no longer depends on which code path produced it. User-visible: finalizeGif output now uses full-stats palette generation and bayer dithering, matching the higher quality the compositor path already had.
durationS was threaded through generateZoomKeyframes and buildAutoZoomFilter but never read in either body; both call sites had to compute frames.length / fps just to satisfy the signature. Drop the parameter and update the one remaining call site (lib/compositing.ts) and autozoom.test.ts accordingly.
Covers normalizeAutoZoom edge cases and compositeRecording: compose receives zoomFilter: undefined when autoZoom is disabled, receives the built filter string when enabled with events, and the thumbnail extraction runs after compose. Also merges compositing.ts's two @webreel/core import statements into one.
Debounced file-change events could start a second runVideo pass while a prior recording was still in flight, racing two headless Chromes and ffmpeg pipelines on the same output. Add a rerunRequested latch so a change during an in-flight run is queued and replayed exactly once, with config reloaded fresh, after the current run finishes.
…nloadFile downloadFile now hashes bytes while streaming to disk and can reject on a mismatch with the expected sha256, deleting the partial file. Adds assertTrustedUrl(url, allowedHosts) to enforce https + host allowlisting for any URL sourced from a remote manifest/API response.
Diff list from types.ts vs README tables: - Top-level WebreelConfig: missing `clickDwell`, `sfx` - Per-video VideoConfig: missing `fps`, `quality`, `clickDwell`, `sfx` - Record command: missing `--dry-run`, `--frames` flags Added the missing rows/flags to both README.md and packages/webreel/README.md, wording condensed from apps/docs/src/app/configuration/page.mdx, apps/docs/src/app/commands/page.mdx, and skills/webreel/SKILL.md. Claude-Session: https://claude.ai/code/session_01Pv8tMQt9GoVpfnpVcdwFxe
Applies assertTrustedUrl to every URL sourced from a remote manifest/API response before it's fetched: the CfT manifest and its per-platform download entries (storage.googleapis.com), the BtbN release asset URL (github.com), and the evermeet.cx API and its zip download URL. Hosts were confirmed against live responses.
Both launch paths passed --no-sandbox unconditionally, disabling Chrome's primary containment against renderer compromise on every recording, even though most environments (macOS, Windows, ordinary Linux desktops) don't need it. --no-sandbox is now applied only when WEBREEL_NO_SANDBOX=1 is set, when running as root, or as a one-time fallback if a sandboxed launch fails. Claude-Session: https://claude.ai/code/session_01Pv8tMQt9GoVpfnpVcdwFxe
…sor/013-shared-ffmpeg-helper
…or/013-shared-ffmpeg-helper # Conflicts: # packages/@webreel/core/src/__tests__/compositor.test.ts # packages/@webreel/core/src/compositor.ts
…lidate,errors}.ts Mechanical split along existing landmarks: config.ts now re-exports the public surface (loadWebreelConfig, validateWebreelConfig, etc.) from focused modules. No behavior changes; config.test.ts assertions unchanged.
…schema-def.ts Known-key allowlists and 'did you mean' suggestion candidates in validate.ts now derive from a single field registry (top-level keys, video keys, step types, autozoom/sfx enums) instead of separately hand-maintained Sets. This registry doubles as the data source for the JSON Schema generator (next step). Hand-written semantic checks (ranges, text-or-selector requirements) remain as code in validate.ts, keyed off registry field names. No behavior change: all 81 config tests pass unchanged, and a key-set comparison against the prior hardcoded allowlists confirms exact parity (including iteration order for the 'Valid actions: ...' error message).
Add scripts/generate-schema.ts (thin CLI) and src/lib/config/schema-generator.ts (pure builder) which walk the schema-def.ts registry to emit apps/docs/public/schema/v1.json, replacing the hand-maintained copy. Add `schema:generate` package script and schema-generate.test.ts (parses as JSON, step-union matches the registry, idempotent across two runs). Regenerated v1.json diffs from the previous hand-written file in exactly one way: the "step" $defs union now lists actions in the same canonical order used everywhere else (validator's VALID_ACTIONS, KNOWN_STEP_KEYS), moving "moveTo" earlier in the array. This is a oneOf union - order does not affect validation - so it's a cosmetic, justified difference. Verified via a recursive structural diff against the pre-regeneration file: 4 array positions changed, all part of this single reorder, nothing else differs. Pre-existing drift between the hand-written schema and the runtime validator (found while building the registry, NOT changed here per the plan - the registry reproduces v1.json's existing semantics): - outDir/output/video.waitFor(string): schema has no minLength; validator rejects empty strings. - viewport width/height: schema requires integer; validator accepts any finite positive number (including non-integers). - top-level/video include items: schema has no minLength; validator rejects empty-string entries. - theme.cursor/theme.hud, sfx, video.thumbnail: schema sets additionalProperties:false; validator does not check for unknown keys inside these sub-objects. - sfx click/key string variant: schema requires minLength:1; validator accepts any string including empty. - video.zoom: schema has no minimum; validator requires > 0. - click.modifiers: schema declares items as strings; validator does not check element types. - key.target object variant: schema (via elementTarget) requires text or selector; validator only checks it's a non-null object. - wait.timeout: schema allows 0 (minimum 0); validator requires > 0.
Regenerates apps/docs/public/schema/v1.json from the registry and fails the build if the committed file doesn't match, so the runtime validator's known keys and the JSON Schema served to editors can't silently diverge again. Verified locally: added a throwaway key to the registry, ran schema:generate, confirmed `git diff --exit-code` on v1.json exits non-zero; reverted and confirmed it's back to exit 0. Confirmed the workflow YAML still parses (js-yaml).
…scalation proc.killed only reflects that kill() was called, not that the process actually exited, so the inherited SIGTERM->SIGKILL escalation from advisor/001 never actually escalated. Check real exit status instead.
…ift gate The generator emitted plain JSON.stringify(obj, null, 2) output, but the committed v1.json is formatted by the pre-commit hook's prettier run (which collapses short arrays onto one line). Result: regenerating against the committed file produced a large formatting-only diff, so the CI drift gate added in the previous commit would have failed on every run even with zero real drift. Fix: scripts/generate-schema.ts now formats its JSON.stringify output through prettier's API (resolveConfig + format with parser: "json") before writing, using the repo's own .prettierrc. Added `prettier` as an explicit devDependency of packages/webreel (it was resolving as a phantom dependency via pnpm's hoisted root node_modules before). Verified: `pnpm --filter webreel run schema:generate && git diff --exit-code apps/docs/public/schema/v1.json` now exits 0 (previously a 124-insertion diff, reproduced before this fix). Two consecutive generator runs are byte-identical. Re-ran the fake-registry-key drift test: nonzero diff with the fake key, zero after reverting. Full suite green: turbo test (150/150), type-check, lint, format:check, build.
…-pipeline-pass-reduction # Conflicts: # .github/workflows/ci.yml
Reduces the autozoom compositing pipeline from three full ffmpeg encodes to two by folding the zoompan pass and the HUD overlay pass into a single invocation. Input 0 (the cursor-overlaid intermediate, a real file) runs through zoompan first via filter_complex; the HUD-only frame stream is still piped in as input 1 and overlaid after zoompan, so HUD stays at fixed viewport coordinates regardless of camera zoom/pan. The cursor-overlay stage (which genuinely needs image2pipe) stays separate from zoompan, avoiding the documented zoompan+image2pipe deadlock. Validated via a hand-run spike of the merged filtergraph (907 real frames, no deadlock, HUD lands unzoomed and uncropped) before implementing. GIF output reuses the same merged pass with the palette filter appended to the filtergraph tail, preserving semantics with one fewer encode for GIF too.
… assertion Integration fix after merging the advisor branches: plan 002 extended connectCDP with an onConnectionLost callback, while plan 008's characterization test was written against the single-argument signature. Claude-Session: https://claude.ai/code/session_01Pv8tMQt9GoVpfnpVcdwFxe
Summary
Fixes #10, #11
The
typeaction had two issues when targeting form inputs viaselector:resolveTarget received the full step object. Since
resolveTargetchecksopts.textfirst, it would try to find an element matching the text-to-type (e.g. "user@example.com") instead of using the CSS selector. This crashes everytypestep that has bothtextandselectorset, including the upstreamform-fillingexample.CDP key events do not update framework-controlled inputs.
dispatchKeyEventfires native keyboard events, but frameworks like React use their own synthetic event system for controlled inputs. Characters were dispatched but never appeared in the field.Additionally, three recording reliability bugs were fixed:
typeTexthangs withcharDelay: 0. During recording, every typed character calledwaitForNextTick()to sync with the capture loop. With zero delay, the typing loop blocked before the capture loop could tick — deadlock.Stale video frames during navigation.
captureScreenshotfired immediately aftertimeline.tick(). During client-side navigation the compositor hadn't finished rendering, producing outdated frames.Orphaned Chrome processes on interrupt.
kill()sent SIGTERM without waiting for exit, and the non-watch record command had no signal handlers. Ctrl+C during recording leftchrome-headless-shellprocesses running.Changes
{ selector, within }toresolveTargetinstead of the fullstepobjectselectoris provided, type each character usingInput.insertTextinstead ofdispatchKeyEvent.insertTextgoes through the browser's native text input pipeline, which frameworks respond totypeTextpath is preserved for non-selector type stepsInput.insertTextto theCDPClienttypewaitForNextTick()whencharDelayis 0 to prevent deadlockcaptureScreenshotto ensure fresh compositor frameschrome.kill()async with SIGKILL fallback after 3s timeoutprocess.on('exit')cleanup handler at Chrome launch timeReproduction
The upstream
form-fillingexample crashes onmain:cd examples/form-filling npx webreel record form-filling --verboseExamples
All HTML pages and webreel configs used below are available in this gist for local reproduction.
Before fix
Uses
keysteps with the HUD enabled so you can see it tries to type — but nothing appears in the inputs becausedispatchKeyEventdoesn't trigger React'sonChange.inserttext-broken.mp4
Config
{ "videos": { "inserttext-broken": { "url": "./web/index.html", "viewport": { "width": 1920, "height": 1080 }, "zoom": 2, "waitFor": "#name", "thumbnail": { "time": 6 }, "steps": [ { "action": "pause", "ms": 800 }, { "action": "click", "selector": "#name" }, { "action": "pause", "ms": 300 }, { "action": "key", "key": "J", "delay": 100 }, { "action": "key", "key": "a", "delay": 100 }, { "action": "key", "key": "n", "delay": 100 }, { "action": "key", "key": "e", "delay": 100 }, { "action": "key", "key": " ", "label": "Space", "delay": 100 }, { "action": "key", "key": "D", "delay": 100 }, { "action": "key", "key": "o", "delay": 100 }, { "action": "key", "key": "e", "delay": 200 }, { "action": "click", "selector": "#email" }, { "action": "pause", "ms": 300 }, { "action": "key", "key": "j", "delay": 100 }, { "action": "key", "key": "a", "delay": 100 }, { "action": "key", "key": "n", "delay": 100 }, { "action": "key", "key": "e", "delay": 100 }, { "action": "key", "key": "@", "delay": 100 }, { "action": "key", "key": "e", "delay": 100 }, { "action": "key", "key": "x", "delay": 100 }, { "action": "key", "key": "a", "delay": 100 }, { "action": "key", "key": "m", "delay": 100 }, { "action": "key", "key": "p", "delay": 100 }, { "action": "key", "key": "l", "delay": 100 }, { "action": "key", "key": "e", "delay": 100 }, { "action": "key", "key": ".", "delay": 100 }, { "action": "key", "key": "c", "delay": 100 }, { "action": "key", "key": "o", "delay": 100 }, { "action": "key", "key": "m", "delay": 100 }, { "action": "pause", "ms": 2000 } ] } } }After fix
Uses
typesteps withselector—insertTextfires theinputevent that React needs, text appears correctly.inserttext-fix.mp4
Config
{ "videos": { "inserttext-fix": { "url": "./web/index.html", "viewport": { "width": 1920, "height": 1080 }, "zoom": 2, "waitFor": "#name", "defaultDelay": 300, "thumbnail": { "time": 6 }, "steps": [ { "action": "pause", "ms": 800 }, { "action": "type", "text": "Jane Doe", "selector": "#name", "charDelay": 60 }, { "action": "type", "text": "jane@example.com", "selector": "#email", "charDelay": 40 }, { "action": "pause", "ms": 2000 } ] } } }With
charDelay: 0Previously,
charDelay: 0would deadlock during recording becausetypeTextblocked onwaitForNextTick()faster than the capture loop could tick. Now it completes instantly.chardelay-zero.mp4
Config
{ "videos": { "chardelay-zero": { "url": "./web/index.html", "viewport": { "width": 1920, "height": 1080 }, "zoom": 2, "waitFor": "#name", "defaultDelay": 300, "thumbnail": { "time": 4 }, "steps": [ { "action": "pause", "ms": 800 }, { "action": "type", "text": "Jane Doe", "selector": "#name", "charDelay": 0 }, { "action": "type", "text": "jane@example.com", "selector": "#email", "charDelay": 0 }, { "action": "pause", "ms": 2000 } ] } } }Stale video frames during navigation
Without the double-rAF sync,
captureScreenshotfires immediately aftertimeline.tick(). During page navigation the compositor hasn't finished rendering, so the capture loop hits consecutive errors and aborts. The test navigates between 4 visually distinct pages (black/Home, blue/Features, purple/Pricing, green/About) 8 times.After fix — all 8 page transitions captured cleanly:
https://github.com/user-attachments/assets/a1a4efb9-1811-4e04-b636-46cde5f30357
Before fix — capture loop aborts after the first navigation, producing a 1.7s video stuck on the home page:
https://github.com/user-attachments/assets/831ceca6-6a49-4928-ad1c-c827f031b5b3
Config
Both configs use the same steps — 8
navigateactions cycling through 4 separate HTML pages with dramatically different background colors.{ "videos": { "stale-frames-fix": { "url": "./web/home.html", "viewport": { "width": 1920, "height": 1080 }, "zoom": 2, "waitFor": "nav", "defaultDelay": 200, "steps": [ { "action": "pause", "ms": 1000 }, { "action": "navigate", "url": "./web/features.html", "delay": 800 }, { "action": "navigate", "url": "./web/pricing.html", "delay": 800 }, { "action": "navigate", "url": "./web/about.html", "delay": 800 }, { "action": "navigate", "url": "./web/home.html", "delay": 800 }, { "action": "navigate", "url": "./web/pricing.html", "delay": 800 }, { "action": "navigate", "url": "./web/features.html", "delay": 800 }, { "action": "navigate", "url": "./web/about.html", "delay": 800 }, { "action": "navigate", "url": "./web/home.html", "delay": 600 }, { "action": "pause", "ms": 1000 } ] } } }Orphaned Chrome processes
On
main, sending SIGINT during a recording leaves 4 orphanedchrome-headless-shellprocesses. This fix registers aprocess.on('exit')cleanup handler at Chrome launch time and adds SIGINT/SIGTERM handling to the record command.Reproduction script
With this config:
{ "videos": { "long-pause": { "url": "data:text/html,<h1>Test</h1>", "viewport": { "width": 1280, "height": 720 }, "steps": [{ "action": "pause", "ms": 60000 }] } } }Test plan
pnpm testpasses (216 tests)pnpm buildclean across all packagesform-fillingexample records successfullycharDelay: 0recording completes without hanging