unsloth/tests/studio/test_composer_rtl_bidi_attribute.py
Ashwin Upadhyay 361f9f9d02
studio/chat: release stuck IME flag when compositionend never fires (#5551)
* studio/chat: release stuck IME flag when compositionend never fires

Chrome on Windows talking to a WSL-hosted Studio (issue #5546) fires
compositionstart + compositionupdate but no compositionend after the
IME commits. The earlier hardening in #5327 cleared the stale flag on
the next non-composing input event, which never arrives in this
sequence, so composingRef stays true forever and the Send button stays
disabled even though the committed CJK text is already in the textarea.

Add a watchdog in both useImeComposerInputHandlers (main + edit
composer) and SharedComposer (compare mode) that runs the same reset
the missing compositionend would have done. The timer is rearmed on
every compositionupdate and on every non-composing input so it only
fires when the IME pipeline has actually gone quiet — normal candidate
selection keeps it alive, the WSL stuck case lets it expire.

Extends the existing IME Playwright smoke with a stuck-compositionend
repro and adds a static guard so the watchdog can't be removed without
the regression tests catching it.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio/chat: re-pin composing flag on IME keydown to close #5546 watchdog gap

The stuck-compositionend watchdog (PR #5551) releases composingRef after
2500 ms of IME silence so Send unwedges in the WSL+Chrome case. The same
release also fires during a long candidate-window pause in healthy IMEs,
which lets a subsequent IME-confirm Enter slip preedit text through
handleSubmit (main composer) or click-Send through send() (compare composer).

Add a keydown gate to both composers: when the browser still reports
nativeEvent.isComposing or keyCode 229, re-pin composingRef and cancel
any pending watchdog so the next form-submit / send() guard refuses.
The Send button stays visually enabled (avoids re-introducing the
stuck-UI bug) but the submit path is blocked until a real compositionend
or non-composing input arrives. Mirrors the existing isComposing guard
shape in shared-composer.onKeyDown.

Tests:
- tests/studio/test_composer_rtl_bidi_attribute.py: two new static
  guards asserting the keydown gate wiring in both composer files.
- tests/studio/playwright_chat_ime_i18n.py: new section 6c repro that
  fires the IME-confirm keydown after the watchdog has cleared, then
  triggers form.requestSubmit() and asserts the preedit text is not
  cleared (would indicate a leaked submit).

Verified across Chromium / Firefox / WebKit via a side-by-side pre-PR
vs post-PR simulation (54 scenarios, zero pageerror or console.error).
The #5546 stuck-end repro still passes (Send re-enables 2.5-3 s after
the silent commit) and the new keydown-repin probe confirms the submit
gate refuses on all three engines.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* studio/chat: re-arm IME watchdog after keydown re-pin (Codex P1)

The keydown re-pin added in 2c3c9793 closed the watchdog-race for
healthy IMEs, but on the same WSL+Chrome no-compositionend path this
PR targets it would re-lock Send permanently: setting composingRef=true
and only *clearing* the watchdog leaves the flag pinned forever if no
follow-up compositionend or non-composing input ever arrives.

Swap clearStuckTimer/clearStuckImeTimer for refreshStuckTimer/
refreshStuckImeTimer in both composer keydown gates so the watchdog
fires once more after every IME keypress. Same visual contract — Send
stays enabled — the submit gate just keeps a 2.5s window before
re-releasing instead of staying locked.

Extends the playwright IME smoke with section 6d: clears composing via
the watchdog, fires an IME keydown, then waits past the re-armed
watchdog window and asserts the form submit actually flushes the
textarea. Two new static guards in test_composer_rtl_bidi_attribute
lock the refresh call into both keydown handlers.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-05-18 06:30:38 -07:00

172 lines
7 KiB
Python

"""Lock down the RTL bidi auto-detection contract on the chat composers.
The browser's Unicode bidi algorithm only flows Arabic / Hebrew / Persian /
Urdu right-to-left when the textarea carries `dir="auto"`. The three
composer surfaces (main chat, inline edit, compare mode) each need the
attribute, and the IME / i18n Playwright smoke must keep its env contract
minimal (no dead `STUDIO_OLD_PW`).
"""
from __future__ import annotations
import re
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
THREAD_TSX = REPO / "studio/frontend/src/components/assistant-ui/thread.tsx"
SHARED_TSX = REPO / "studio/frontend/src/features/chat/shared-composer.tsx"
WORKFLOW_YML = REPO / ".github/workflows/studio-ui-smoke.yml"
IME_PY = REPO / "tests/studio/playwright_chat_ime_i18n.py"
def _block_around(src: str, anchor: str, radius: int = 600) -> str:
idx = src.find(anchor)
assert idx != -1, f"anchor {anchor!r} not found"
return src[max(idx - radius, 0) : idx + radius]
def test_main_composer_has_dir_auto():
block = _block_around(THREAD_TSX.read_text(), 'aria-label="Message input"')
assert 'dir="auto"' in block, 'main composer is missing dir="auto"'
def test_edit_composer_has_dir_auto():
block = _block_around(THREAD_TSX.read_text(), "aui-edit-composer-input")
assert 'dir="auto"' in block, 'edit composer is missing dir="auto"'
def test_compare_composer_has_dir_auto():
block = _block_around(SHARED_TSX.read_text(), "Send to both models")
assert 'dir="auto"' in block, 'compare composer is missing dir="auto"'
def test_ime_workflow_step_does_not_set_studio_old_pw():
yml = WORKFLOW_YML.read_text()
drive_idx = yml.find("Drive IME + multilingual paste regression")
assert drive_idx != -1, "IME drive step not found in workflow"
next_step_idx = yml.find("- name:", drive_idx + 1)
drive_block = yml[drive_idx : next_step_idx if next_step_idx != -1 else None]
assert (
"STUDIO_OLD_PW" not in drive_block
), "IME drive step still passes dead STUDIO_OLD_PW env var"
assert "STUDIO_NEW_PW" in drive_block, "IME drive step missing STUDIO_NEW_PW"
def test_ime_pass_password_step_does_not_export_old_pw():
yml = WORKFLOW_YML.read_text()
pass_idx = yml.find("Pass bootstrap pw for IME / i18n test")
assert pass_idx != -1, "IME password setup step not found"
next_step_idx = yml.find("- name:", pass_idx + 1)
pass_block = yml[pass_idx : next_step_idx if next_step_idx != -1 else None]
assert (
"STUDIO_IME_OLD_PW" not in pass_block
), "IME password setup still exports dead STUDIO_IME_OLD_PW"
assert "STUDIO_IME_NEW_PW" in pass_block
def test_ime_playwright_script_does_not_read_studio_old_pw():
src = IME_PY.read_text()
code_only = re.sub(r'""".*?"""', "", src, flags = re.DOTALL)
assert (
"STUDIO_OLD_PW" not in code_only
), "IME Playwright script still references dead STUDIO_OLD_PW env var"
assert 'os.environ["STUDIO_NEW_PW"]' in code_only
def test_main_composer_has_stuck_compositionend_watchdog():
"""Issue #5546: Chrome on Windows over WSL never emits compositionend
after the IME commit. The composer keeps a watchdog that releases the
composing flag once events go silent; without it Send stays disabled
forever and CJK input is effectively dropped."""
src = THREAD_TSX.read_text()
assert "IME_STUCK_TIMEOUT_MS" in src, (
"main composer is missing the stuck-compositionend watchdog " "(issue #5546)"
)
assert "onCompositionUpdate" in src, (
"main composer is missing onCompositionUpdate wiring; the "
"watchdog only resets while the IME is actively emitting events"
)
def test_compare_composer_has_stuck_compositionend_watchdog():
src = SHARED_TSX.read_text()
assert "IME_STUCK_TIMEOUT_MS" in src, (
"compare composer is missing the stuck-compositionend watchdog " "(issue #5546)"
)
assert (
"onCompositionUpdate" in src
), "compare composer is missing onCompositionUpdate wiring"
def test_main_composer_keydown_repins_composing_during_ime():
"""Issue #5546 watchdog can clear composingRef during a long candidate
pause; the IME keydown gate must re-pin it so a follow-up Enter does not
submit preedit text."""
src = THREAD_TSX.read_text()
assert "onKeyDown" in src, "main composer is missing onKeyDown IME gate"
assert "e.nativeEvent.isComposing" in src and "keyCode === 229" in src, (
"main composer keydown gate must check both nativeEvent.isComposing "
"and the IME keyCode 229 sentinel"
)
def test_compare_composer_keydown_repins_composing_during_ime():
"""Compare composer onKeyDown re-pins composingRef on IME keypress so a
follow-up click-Send during the watchdog window does not slip preedit
text through."""
src = SHARED_TSX.read_text()
assert "composingRef.current = true" in src, (
"compare composer keydown gate must re-pin composingRef when the "
"browser still considers the IME active"
)
def _extract_block(src: str, anchor: str, opener: str = "(", closer: str = ")") -> str:
"""Return the source between the first balanced opener/closer that
starts at or after `anchor`. Used to scope assertions to a specific
handler so a re-arm call in some other function does not satisfy
the gate test."""
start = src.find(anchor)
assert start != -1, f"anchor {anchor!r} not found"
open_idx = src.find(opener, start)
assert open_idx != -1, f"opener {opener!r} after {anchor!r} not found"
depth = 0
for i in range(open_idx, len(src)):
c = src[i]
if c == opener:
depth += 1
elif c == closer:
depth -= 1
if depth == 0:
return src[start : i + 1]
raise AssertionError(f"unbalanced {opener!r}/{closer!r} after {anchor!r}")
def test_main_composer_keydown_rearms_watchdog():
"""After the keydown re-pin sets composingRef=true the watchdog must
be re-armed; otherwise the WSL+Chrome no-compositionend path this PR
targets would lock Send permanently after any IME keypress
(Codex P1 on commit 597af0d0)."""
src = THREAD_TSX.read_text()
block = _extract_block(src, "const onKeyDown = useCallback")
assert "refreshStuckTimer" in block, (
"main composer keydown gate must call refreshStuckTimer after "
"re-pinning composingRef so the watchdog runs again on the "
"stuck-compositionend path"
)
assert "clearStuckTimer();" not in block.replace("clearStuckTimer\n", "").replace(
"clearStuckTimer,", ""
), (
"main composer keydown gate must not leave the watchdog only "
"cleared — that's the Codex P1 regression"
)
def test_compare_composer_keydown_rearms_watchdog():
"""Same re-arm contract for the compare-mode composer."""
src = SHARED_TSX.read_text()
block = _extract_block(src, "function onKeyDown", opener = "{", closer = "}")
assert "refreshStuckImeTimer" in block, (
"compare composer keydown gate must call refreshStuckImeTimer "
"after re-pinning composingRef"
)