Skip to content

fix: record native and widget selects, and give id-less save buttons a selector - #1537

Draft
abrichr wants to merge 14 commits into
mainfrom
claude/record-onboarding-blockers
Draft

abrichr wants to merge 14 commits into
mainfrom
claude/record-onboarding-blockers

Conversation

@abrichr

@abrichr abrichr commented Oct 25, 2026

Copy link
Copy Markdown
Member

The onboarding-effort benchmark (#1447) found two blockers that stop a first real recording on common web apps. This fixes both. The third blocker, an unarmed save on create-new forms, needs a new identity check and comes in a separate PR.

1. Drop-downs no longer stop the recording

openadapt-flow record stopped every recording at a native <select> choice ("a native selection is not a qualified browser recording action"). That made any workflow with a drop-down unrecordable. The benchmark hit it on Trac's ticket fields, and on NetBox's TomSelect fields, which send change to a hidden select. Now: Native select. A trusted change on a visible native select is recorded as the chosen option's label typed into the select. Replay types the label and reads the select's chosen option back (focused_select_label_at), so a select that ends on another option stops the run. With --param <select name>, each run can choose a different option. Type-ahead. Type-ahead fires one change per letter. These coalesce like keystrokes in one field, so the run keeps the last choice. No Enter. Enter on a select inside a form submits the form, through the form's first submit button. I saw this happen while recording a Trac-shaped form. So no Enter is recorded after the label. An Enter the person really presses on a select in a form shows up as the click on the button it submits. Widgets over a hidden select (TomSelect, Select2). The change the widget's script sends to the hidden select is untrusted and is skipped. The person's clicks and typing on the visible widget are recorded as usual. Still refused: <select multiple>, and a select that holds a declared secret. 2. Save buttons without an id get a selector The Playwright backend's structural locator, used by the scripted Recorder, built a selector from a unique id only. It also called <input type=submit name=submit value="Create ticket"> a textbox named "submit". So Trac's and NetBox's saves had no selector, and qualification refused them ("save step … has no structural selector"). The locator now falls back to unique data-testid, data-test and name attributes, the same ones the interactive recorder already uses, and then to a submit input's value. It names button inputs by their label. Two identical unnamed buttons still get no selector, only role and name. Tests (real headless Chromium) tests/test_interactive_recorder_gestures.py: A native select chosen by real key events records click and type, and compiles. Replay with sel=Label Alpha chooses that option, although Label Beta was recorded. A TomSelect-shaped widget (hidden select, visible control, script-dispatched change) records its two clicks and no refusal. A script-set select value records nothing and doesn't refuse. The old refusal test is replaced. tests/test_structural_locator_idless.py: The locator gives input[name="submit"] / button "Create ticket", and button[name="_create"] / button "Create". Twin "Save" buttons get no selector. A scripted Recorder run compiled by the real compiler leaves the save step with that selector. On main the first test fails with the reported bug (role='textbox', name='submit', no selector). Regression: the interactive recorder, frames, gestures, structural rung, submit binding, browser attach, recorder and standard-change suites pass (171). ruff and mypy are clean. Not in this PR Create-new forms have no record identity next to the save. I checked record --identifier FIELD, the documented remedy, on a Trac-shaped form: it arms the save with a pixel crop of the field, and replay with a new summary halts on an identity mismatch. So it isn't usable for create forms. The follow-up extends the submit binding to the labelled submit button of the form the run filled, adds a check that each filled field still holds the typed value, and counts that as armed identity. Issues for the 9 clinical-write violations behind #1431's pre-certified bundle: #1532 (unnamed field clicks need a risk review), #1533 (#create opener read as a write) and #1534 (no idempotency key without qualification). 🤖 Generated with Claude Code

…a selector

The interactive recorder stopped every recording at a native <select>
choice, so `record` couldn't capture any workflow with a drop-down
(Trac's ticket fields, NetBox's TomSelect fields). A trusted change on a
visible select is now recorded as the option's label typed into the
select; replay types it and reads the chosen option back. Type-ahead
coalesces like keystrokes in one field. No Enter is recorded: Enter on a
select inside a form submits the form. A change the page's own script
sends to a hidden select (TomSelect, Select2) is an echo of clicks already
recorded on the widget, so it is skipped. Multiple-choice and secret
selects are still refused.

The Playwright backend's structural locator built a selector from a
unique id only and called <input type=submit> a textbox named after its
name attribute. It now falls back to unique data-testid, data-test and
name attributes, then a submit input's value, and names button inputs by
their label, so id-less saves (Trac's input[name=submit], NetBox's
button[name=_create]) carry the selector qualification needs.

Found by the onboarding-effort benchmark (#1447).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@abrichr abrichr left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coordinator review (AI-assisted). One fix before merge. The uniqueness check counts matches in document but never confirms the match is the clicked element. For an element in a shadow root or another document, one look-alike in the main document still counts as 1.

Fix (playwright_backend.py, unique()):

  • query actionable.getRootNode();
  • require root.querySelectorAll(c).length === 1 && root.querySelector(c) === actionable;
  • add a shadow-DOM test.

Otherwise sound: real-browser tests, untrusted change events ignored, and twin Save buttons get no selector.

Review of #1537: the uniqueness check counted matches in `document` but
never confirmed the single match was the clicked element. A button in a
shadow root with a same-named look-alike in the page got the look-alike's
selector, and a replay would click the wrong Save. The check now runs in
the clicked element's own document and requires the one match to be that
element, for the id too. Inside a shadow root no selector is built:
Playwright's CSS locator pierces open shadow roots at replay, so a
selector unique in one root could match a look-alike elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abrichr

abrichr commented Oct 25, 2026

Copy link
Copy Markdown
Member Author

Fixed in 57946df:

  • unique() now queries the clicked element's own document and requires exactly one match that is the clicked element. The #id path uses the same check.
  • Inside a shadow root no selector is built. Playwright's CSS locator pierces open shadow roots at replay, so a selector unique in one root could still match a look-alike elsewhere.

New test: test_a_lookalike_in_another_root_never_names_the_clicked_button. It puts a shadow-root Save next to a same-named light-DOM Save. On the previous commit it fails with the shadow button getting button[name="save"] (the look-alike's selector); now it gets role and name only, and the light-DOM Save keeps its selector.

Ran locally with one worker: the locator, structural-rung, submit-binding, recorder, gesture and browser-attach suites, 155 passed. One failure was test_browser_attach.py::test_page_closure_states_its_debounce_limit, a settle-timing test that passes on its own.

@abrichr

abrichr commented Oct 25, 2026

Copy link
Copy Markdown
Member Author

AI re-review (coordinator). Fixed in 57946df: unique() queries actionable.getRootNode() and requires querySelectorAll(c).length === 1 && querySelector(c) === actionable; the #id path uses the same check. Inside a shadow root it builds no selector at all, which is stricter than asked and right, since Playwright CSS pierces open roots at replay. test_a_lookalike_in_another_root_never_names_the_clicked_button covers it. Nothing open. Queuing for auto-merge.

@abrichr
abrichr enabled auto-merge October 25, 2026 05:28
…selectors for buttons

The read-back of a focused select is decisive: another label (type-ahead
stopping on Mrs for Mr, or a value that is no option) never counts as landed.
A choice whose label another option shares is refused at record time. A
button's name attribute, and a bracketed row-position name, never become a
selector; buttons keep their role and label.
@abrichr

abrichr commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Independent adversarial review (recorded, 2026-11-03)

The reviewer was a separate agent with read-only access to the diff after the merge with main.

Round 1: block.

  • Blocking: _typed_input_landed fell through to OCR or pixels when the select read-back showed another label. Type-ahead landing on "Mrs" for "Mr" passed, because short labels are accepted on any pixel change. Fixed: a read-back string is decisive.
  • Should-fix:
    • Duplicate option labels. Fixed: refused at record time, case-insensitive.
    • A button's name selector could name another button (Approve/Deny). Fixed: no name selectors for buttons.
    • A bracketed name is a row position. Fixed: never a selector.
  • Low: the recorder stored option.text while the read-back used option.label. Fixed: both use label.

Round 2 (2f5a449): approve.

Follow-ups, non-blocking:

  1. If the page moves focus away after the change, the focused-select reader returns None and the check falls back to OCR. Read the select at the field point whatever has focus.
  2. The recorder collapses whitespace runs, while the read-back squash removes all whitespace. This is cosmetic.

replayer._typed_input_landed: keep this PR's decisive select read-back
(another label never counts as landed), then main's no-change refusal and
exact OCR value check.
On the CI runner the click on the native select and the first type-ahead
change reached the recorder in one batch, which it refuses by design
(no exact frame between two actions). The test now pumps until the click
is kept, so the changes never share its batch.
@abrichr

abrichr commented Nov 4, 2026

Copy link
Copy Markdown
Member Author

From: Grok, To: coordinator — review of #1537 at 5d4ce5a

Verdict: APPROVE.

What I checked:

  • Controls: the PR's tests with main's openadapt_flow gave 10 failed and 25 passed. The failures were all six select tests, two structural-locator tests, and both test_dropdown_showing_another_option_fails_without_ocr cases. At the head: 35 passed.
  • Wider run: test_interactive_recorder*, frames, gestures and structural_locator_idless gave 29 passed (-n 4). ruff check and ruff format --check pass on the changed files.
  • My own probe (a throwaway test, now deleted): a form with a native select and <input type=submit name=submit value="Create ticket">. I clicked the select, typed "Label Beta" and pressed Enter, and the form submitted once. The recording was click #sel, type "Label Beta", click input[name="submit"]. So the PR's claim holds: an Enter that submits the form is kept as the click on its submit button, and nothing is silently lost.
  • Diff read: an untrusted change is skipped; a multiple select, a secret select, or a label two options share is refused. kind == "select" goes through the same input-session check as typed input. Any read-back string is now decisive in _typed_input_landed, and only "no focused select" falls back to the visual check.

Non-blocking notes:

  1. The structural locator now gives checkbox and radio inputs role checkbox or radio; before, it was textbox. That's right going forward. A bundle recorded before this change with role textbox on a checkbox may now fail closed at the identity check on replay. That's worth a line in the changelog.
  2. The "no name selector for buttons" fix covers only the Playwright backend's locator. The interactive recorder's own selector still emits input[name="submit"] for a submit input (seen in my probe). That's pre-existing and guarded by its uniqueness check, so it's not this PR's regression. But the Approve/Deny concern still applies on that path.
  3. Earlier follow-ups still open: read the select at the field point whatever has focus, and the whitespace collapse/squash mismatch (cosmetic).

Not tested: real Trac or NetBox pages, and real TomSelect or Select2 (only the TomSelect-shaped fixture).

Linux Chromium opens a popup when a select is clicked, so typed type-ahead
only highlights an option and the change fires when the next click closes
the popup. Recording then got the change and that click in one batch, which
the recorder refuses; replay could not commit the typed label at all.

- Replay: a type step whose focused target is a native <select> chooses the
  option labelled exactly as the value (select_option, no keystrokes), then
  runs the existing read-back. No single enabled option with that label, a
  multiple or a disabled select is refused as a safety halt, never typed.
  Recording and other type_text callers are unchanged.
- Tests: the select recording helper commits the choice with Enter (no step)
  before the next click; replay picks the labelled option with zero key
  events; a missing label refuses with the select untouched.
@abrichr

abrichr commented Nov 9, 2026

Copy link
Copy Markdown
Member Author

Independent adversarial re-review: BLOCK

From: Codex
Reviewed-Head: 41e908f

P1: a normalized-label collision selects another option and reports success. In a genuine headless recording → compile → replay, the requested label was Label Alpha. The replay select contained an earlier option labelled Label Alpha with value v-shadow, followed by the exact label with value v-alpha. Replay chose v-shadow, marked the type step input_verified=True, and returned success=True; all three steps passed and zero key events fired. The same scenario at literal parent 9bd7be168928f3852f34496d7165469dc024a57c halted with the original empty value.

The new check counts raw exact labels, but pinned Playwright 1.61 matches normalized whitespace too and takes the first matching option. The unchanged squashed-label readback then accepts the wrong value. Admission, actuation and readback need one matching model and a bound actual option identity; ambiguous choices must refuse before delivery. Checking the displayed label alone cannot establish which option was selected.

The plain-label control succeeds with v-alpha. A separate unique whitespace-normalized label still refuses at both revisions; that is an availability limit, not presented as a new regression. The whole gesture module passes 10/10 at the head; its parent has seven passes and three recording-batch BrowserAttachError failures. All three existing native-select primitive controls pass at head, parent and actual pre-merge main 6c1a7dd5180bef49e2fa661ca15670f6066ce001; the reported base failures were not reproduced in this environment.

All 35 cases and 105 phases reconcile with exact JUnit identities, no setup/teardown failures, skips or deselections, and unchanged bound sources. Ruff lint passes. Formatting flags only the new reviewer probe; the three original PR files pass format, and the following Python-3.10 syntax command was not reached. This is synthetic real-browser evidence in one head-locked dependency environment, not real-app or full-CI qualification.

No per-app engine code: yes. Fix the class: use one native-option matching and identity model across delivery paths. Held-out real-app evidence: not shown by this review.

Playwright's select_option(label=) and option.label collapse whitespace, so
an earlier "Label  Alpha" option was chosen for "Label Alpha" and the
step reported success.

- The backend reads each option's raw label (label attribute when set and
  not empty, else textContent) and requires exactly one enabled option equal
  to the value. It selects that option by index and fires input and change,
  then reads the selected option again and refuses unless it is that option
  with exactly that label. The existing read-back still runs.
- A refusal after the events fired is StructuralRefusalAfterDelivery, a
  StructuralResolutionRefused that reports the delivery as attempted. Typed
  refusals now pass through the guarded keyboard lease instead of becoming
  ActionDeliveryUncertain.
- Tests: a whitespace decoy before the exact option (as text and as a label
  attribute) picks the exact one; the decoy alone, two exact duplicates,
  leading, trailing and no-break-space variants, and a disabled exact
  option refuse with the select unchanged and no change event; each variant
  is chosen only by its own exact string.
Replay compared raw option labels byte for byte while the recorder stores
the label whitespace-squashed, so a formatted multi-line option recorded as
"Dog" refused at replay.

- NATIVE_OPTION_LABEL_JS (playwright_backend) is the one label function:
  option.label, runs of JavaScript whitespace made one space, trimmed, case
  kept. The interactive recorder's change listener now uses it in place of
  its inline copy, and replay matches each option through it.
- Exactly one option must match and be enabled; zero, several (a
  "Label  Alpha" beside "Label Alpha" is two) or a disabled match refuse
  with nothing delivered. The selectedIndex plus input/change delivery, the
  re-read of index, value and label, StructuralRefusalAfterDelivery and the
  guarded pass-through stay.
- Tests: whitespace decoy and duplicates refuse, a disabled match refuses, a
  multi-line option selects, and one case per whitespace class shows the
  stored label (double space, tab, newline, leading/trailing, NBSP, em
  space, label attribute collapse; zero-width space and case stay).
@abrichr

abrichr commented Nov 9, 2026

Copy link
Copy Markdown
Member Author

Independent review: BLOCK

From: Codex
Reviewed-Head: ee66bea

[P1] A synchronous change handler can replace the selected option and still receive a verified success. In packages/flow/openadapt_flow/backends/playwright_backend.py:1193, _SELECT_NATIVE_OPTION_JS reads e.options[index].value after dispatching input/change. That lets the handler replace the expected v-alpha option with a different v-shadow option at the same index and canonical label; the later read agrees with the replacement instead of the intended option.

Own headless Chromium record→compile→replay probe snapshots the original option before delivery. With the replacement handler, the original node becomes disconnected, selected value becomes v-shadow, one change event fires and no key events fire, yet both report.success and input_verified are True. The unchanged positive passes. Preserve the intended node/value before dispatch and refuse uncertain delivery if handlers replace or alter that identity; do not retry after delivery. Cover synchronous input and change handlers through the same identity model.

Cloud42550404708: 66 collected cases /198 setup-call-teardown phases across head, literal parent cab834c and the primitive baseline. Head: original review probes3P, whole gesture module24P, native primitives3P, new controls1P/1 assertion failure. Parent: original1P/2 assertion failures, gesture23P, primitives3P, new controls1P/1 assertion failure. Baseline primitives3P. No skips, errors or deselections; Item/JUnit identities and all phases reconcile. This new delivery witness fails on both head and literal parent, so it is an unresolved sibling, not a regression introduced by the canonical-label delta. The original label-normalization blockers now pass at head.

Required Ruff, format, Python3.10 syntax-rule and scoped Mypy checks pass. The byte-exact old review probe retains a separately measured wrapping-only format failure; it is not included in that required-check pass. Source pins and named dependencies stayed unchanged; 617 loaded source paths/ref were independently rehashed against their Git or frozen probe inputs. The successful workflow status means evidence accounting completed, not that all test cases passed.

No per-app engine code: yes. Fix the class: still blocked on retaining option identity across event delivery. Held-out app evidence: not shown; these are synthetic browser cases, not a real-app qualification.

A page change handler could replace the chosen option with a same-label,
same-index twin of another value; the check read the expected value after
the events ran, so it agreed with the replacement and the step verified.

- The page captures the chosen option element, its value and its label in a
  record before input and change fire, and the expected value is never read
  from the page after them. The choice holds only while that same element is
  connected, inside the select, its only selected option, with the captured
  value and label. That is checked right after the events (in the page) and
  again from Python; otherwise StructuralRefusalAfterDelivery, which now also
  marks the step's input unverified.
- The backend keeps the record (native_select_choice_holds), and the
  replayer's typed-input read-back refuses when it no longer holds, so a
  handler that swaps the option later is caught too. Every text delivery
  clears the record.
- Tests through record, compile and replay: replacing the option on change
  or on input, changing only its value, removing it, and replacing it after
  a timeout each halt the run with input unverified and no key events; a
  handler that edits unrelated DOM still verifies.
@abrichr

abrichr commented Nov 9, 2026

Copy link
Copy Markdown
Member Author

From: Codex

APPROVE
Reviewed-Head: 50b6abf
Compared-Parent: ee66bea

The head passed all 43 cases: the whole 30-case gesture module, five unchanged earlier probes, five mutation cases and three native primitive cases. The literal parent passed 32 of 37 cases, with five assertion failures. All 240 setup/call/teardown phases reconcile with collected items and JUnit; no errors, skips, deselections or timeouts. I independently checked 417 loaded source/probe paths against the exact Git refs or frozen probe source. Required Ruff, formatting, Python 3.10 compatibility and scoped Mypy checks passed. The unchanged older three-probe formatting diagnostic still exits 1; that diagnostic is not a formatting pass.

The deferred case measured the genuine chooser returning true, then the choice mutation completing, then the genuine retained-choice check returning false. Replay stopped before the next click; the benign control reached it. Replacement and synchronous input/value cases reproduce false verification on the parent. Its removal case halted with an unset verification result, and its deferred timing was inconclusive; I do not count either as a proven false-success witness. Timing observations use read-through instrumentation and synthetic headless Chromium/server fixtures.

No per-app engine code: yes. The repair uses one node/value/label ownership model.
Fix the class: yes. Replacement, removal and synchronous/deferred mutation use that model, with existing assertions retained.
Held-out evidence: additional synthetic mutation cases passed; real-app and held-out instance qualification has not been shown.

@abrichr abrichr closed this Nov 9, 2026
auto-merge was automatically disabled November 9, 2026 04:16

Pull request was closed

@abrichr abrichr reopened this Nov 9, 2026
# Conflicts:
#	packages/flow/openadapt_flow/runtime/replayer.py
@abrichr

abrichr commented Nov 9, 2026

Copy link
Copy Markdown
Member Author

Retriggering CI on a fresh merge ref; the gitleaks failure predates #2122.

@abrichr abrichr closed this Nov 9, 2026
@abrichr abrichr reopened this Nov 9, 2026
@abrichr
abrichr marked this pull request as draft November 9, 2026 22:39
@abrichr

abrichr commented Nov 9, 2026

Copy link
Copy Markdown
Member Author

Parked as a draft. It's screen-heuristic recording work (native and widget selects), and test_playwright_frame_context.py::test_nested_frame_post_dispatch_error_is_typed_uncertainty[keyboard] fails again ('Alpha' == 'ZAlpha') after the 2-round cap. Revive it with a fix for that test if select recording becomes a priority.

This branch has not been deployed

No deployments
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