Skip to content

fix(type): use insertText for framework-compatible input typing#13

Open
arielconti10 wants to merge 60 commits into
vercel-labs:mainfrom
arielconti10:fix/type-inserttext
Open

fix(type): use insertText for framework-compatible input typing#13
arielconti10 wants to merge 60 commits into
vercel-labs:mainfrom
arielconti10:fix/type-inserttext

Conversation

@arielconti10

@arielconti10 arielconti10 commented Mar 5, 2026

Copy link
Copy Markdown

Summary

Fixes #10, #11

The type action had two issues when targeting form inputs via selector:

  1. resolveTarget received the full step object. Since resolveTarget checks opts.text first, 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 every type step that has both text and selector set, including the upstream form-filling example.

  2. CDP key events do not update framework-controlled inputs. dispatchKeyEvent fires 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:

  1. typeText hangs with charDelay: 0. During recording, every typed character called waitForNextTick() to sync with the capture loop. With zero delay, the typing loop blocked before the capture loop could tick — deadlock.

  2. Stale video frames during navigation. captureScreenshot fired immediately after timeline.tick(). During client-side navigation the compositor hadn't finished rendering, producing outdated frames.

  3. 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 left chrome-headless-shell processes running.

Changes

  • Pass only { selector, within } to resolveTarget instead of the full step object
  • When a selector is provided, type each character using Input.insertText instead of dispatchKeyEvent. insertText goes through the browser's native text input pipeline, which frameworks respond to
  • The original typeText path is preserved for non-selector type steps
  • Add Input.insertText to the CDPClient type
  • Skip waitForNextTick() when charDelay is 0 to prevent deadlock
  • Add double-rAF sync before captureScreenshot to ensure fresh compositor frames
  • Make chrome.kill() async with SIGKILL fallback after 3s timeout
  • Register process.on('exit') cleanup handler at Chrome launch time
  • Add SIGINT/SIGTERM handlers to the non-watch record command

Reproduction

The upstream form-filling example crashes on main:

cd examples/form-filling
npx webreel record form-filling --verbose
Recording: form-filling
[step 0] pause 500ms
[step 1] type "user@example.com"
Step 1 (type) failed: Element not found: text="user@example.com"

Examples

All HTML pages and webreel configs used below are available in this gist for local reproduction.

Before fix

Uses key steps with the HUD enabled so you can see it tries to type — but nothing appears in the inputs because dispatchKeyEvent doesn't trigger React's onChange.

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 type steps with selectorinsertText fires the input event 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: 0

Previously, charDelay: 0 would deadlock during recording because typeText blocked on waitForNextTick() 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, captureScreenshot fires immediately after timeline.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.

Fixed Broken (without double-rAF)
Duration 8.25s (495 frames) 1.70s (102 frames)
Pages captured All 8 transitions Home only, then capture loop dies

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 navigate actions 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 orphaned chrome-headless-shell processes. This fix registers a process.on('exit') cleanup handler at Chrome launch time and adds SIGINT/SIGTERM handling to the record command.

BEFORE FIX (main @ fec3c34)
========================================
=== Starting recording (will interrupt after 3s) ===
Recording: long-pause
[step 0] pause 60000ms

=== Sending SIGINT to webreel (PID 42085) ===

=== Checking for orphaned chrome-headless-shell processes ===
FAIL: 4 orphaned Chrome process(es) found
  PID 42089 chrome-headless-shell
  PID 42087 chrome-headless-shell
  PID 42094 chrome-headless-shell
  PID 42090 chrome-headless-shell


AFTER FIX (fix/type-inserttext @ 26992dc)
========================================
=== Starting recording (will interrupt after 3s) ===
Recording: long-pause
[step 0] pause 60000ms

=== Sending SIGINT to webreel (PID 42337) ===

Interrupted. Cleaning up...

=== Checking for orphaned chrome-headless-shell processes ===
PASS: No orphaned Chrome processes
Reproduction script
#!/bin/bash
# test-chrome-cleanup.sh <path-to-webreel-cli>
# Starts a long recording, sends SIGINT after 3s, checks for orphaned Chrome.

CLI="${1:?Usage: $0 <path-to-webreel-cli>}"
DIR="$(cd "$(dirname "$0")" && pwd)"

CHROME_PATTERN=".webreel/bin/chrome-headless-shell"

pkill -f "$CHROME_PATTERN" 2>/dev/null || true
sleep 1

echo "=== Starting recording (will interrupt after 3s) ==="
node "$CLI" record -c "$DIR/webreel.config.json" --verbose &
PID=$!

sleep 3
echo ""
echo "=== Sending SIGINT to webreel (PID $PID) ==="
kill -INT "$PID" 2>/dev/null || true

sleep 4

echo ""
echo "=== Checking for orphaned chrome-headless-shell processes ==="
if ps aux | grep "$CHROME_PATTERN" | grep -v grep > /dev/null 2>&1; then
  echo "FAIL: orphaned Chrome processes found"
  ps aux | grep "$CHROME_PATTERN" | grep -v grep | awk '{print "  PID", $2}'
  pkill -f "$CHROME_PATTERN" 2>/dev/null || true
  exit 1
else
  echo "PASS: No orphaned Chrome processes"
  exit 0
fi

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 test passes (216 tests)
  • pnpm build clean across all packages
  • form-filling example records successfully
  • Tested against a React controlled input form (character-by-character animation visible, form submits correctly)
  • charDelay: 0 recording completes without hanging
  • Ctrl+C during recording leaves no orphaned Chrome processes
  • Navigation recording captures all page transitions (8 navigations between 4 distinct pages)
  • Tested against a production Next.js app with React controlled inputs

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.
@vercel

vercel Bot commented Mar 5, 2026

Copy link
Copy Markdown

@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
Comment thread packages/@webreel/core/src/chrome.ts Outdated
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.
@ctate

ctate commented Mar 6, 2026

Copy link
Copy Markdown
Collaborator

Thanks for this excellent contribution! The root cause analysis, before/after videos, and test coverage are all really well done. The insertText approach for framework-compatible typing is the right call.

One thing to fix before merging: the two empty catch {} blocks in chrome.ts (lines 273 and 276) will fail CI's ESLint no-empty rule. Adding a short comment inside each one (e.g. // ignore) should do it.

Everything else looks good — build, type-check, and all 215 tests pass cleanly.

@arielconti10

arielconti10 commented Mar 7, 2026

Copy link
Copy Markdown
Author

Thanks for this excellent contribution! The root cause analysis, before/after videos, and test coverage are all really well done. The insertText approach for framework-compatible typing is the right call.

One thing to fix before merging: the two empty catch {} blocks in chrome.ts (lines 273 and 276) will fail CI's ESLint no-empty rule. Adding a short comment inside each one (e.g. // ignore) should do it.

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!

@denzo

denzo commented Mar 14, 2026

Copy link
Copy Markdown

Looking forward to this being released.

My Claude wrote a workaround directly in the node_modules 😂

@Crunchyman-ralph

Copy link
Copy Markdown

Same, excited for this to be released!

@lucharo

lucharo commented Jun 24, 2026

Copy link
Copy Markdown

+1 — this also fixes typing in headless recording (chrome-headless-shell). There the per-character Input.dispatchKeyEvent({ type: 'char' }) silently no-ops, so webreel record produces clips with empty inputs; routing each char through Input.insertText fixes it (verified locally against a React app — text now lands and the task is created). With 0.1.4 still the only npm release, this PR is the only way to get headless type working, so it'd be great to get it over the line. Happy to help if anything's blocking. cc @ctate 🙏

arielconti10 and others added 19 commits July 3, 2026 19:12
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
…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
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.

type action fails with selector + text due to incorrect target resolution

7 participants