# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

"""Studio chat composer IME + multilingual regression smoke.

Covers: stuck IME composition (#5318 / PR #5327), multilingual paste round-trip,
stuck compositionend (#5546), and Mac input-method switch recovery (keydown/blur).
Model-free; the bug surface is the composer, not inference. Env contract matches
playwright_chat_ui.py: BASE_URL, STUDIO_NEW_PW, PW_ART_DIR, STUDIO_UI_STRICT.
"""

import os
import sys
from pathlib import Path

from playwright.sync_api import expect, sync_playwright

sys.path.insert(0, str(Path(__file__).resolve().parent))
from _playwright_robust import (  # noqa: E402
    chromium_launch_args,
    click_and_wait_for_response,
    install_view_transition_killer,
    install_wall_clock_watchdog,
    is_benign_console_error,
    is_benign_page_error,
    recover_or_replace_page,
    wait_for_health,
)

BASE = os.environ["BASE_URL"]
NEW = os.environ["STUDIO_NEW_PW"]
ART_DIR = os.environ.get("PW_ART_DIR", "logs/playwright_ime")
ART = Path(ART_DIR)
ART.mkdir(parents = True, exist_ok = True)
STRICT = os.environ.get("STUDIO_UI_STRICT", "0") == "1"

# Wall-clock cap: 5 min leaves cold-launch headroom over the 30-60s run.
WALL_TIMEOUT_S = float(os.environ.get("STUDIO_IME_WALL_TIMEOUT_S", "300"))


# One greeting + arithmetic per script; each catches a distinct Unicode class.
I18N_SAMPLES = [
    ("en", "English", "Hello, 1+1=2"),
    ("zh-CN", "Chinese (Simplified)", "你好，1+1=2"),
    ("es", "Spanish", "Hola, 1+1=2"),
    ("hi", "Hindi (Devanagari)", "नमस्ते, 1+1=2"),
    ("ar", "Arabic (RTL)", "مرحبا، ١+١=٢"),
    ("bn", "Bengali", "নমস্কার, ১+১=২"),
    ("pt", "Portuguese", "Olá, 1+1=2"),
    ("ru", "Russian (Cyrillic)", "Привет, 1+1=2"),
    ("ja", "Japanese", "こんにちは、1+1=2"),
    ("pa", "Punjabi (Gurmukhi)", "ਸਤ ਸ੍ਰੀ ਅਕਾਲ, 1+1=2"),
    ("de", "German", "Hallo, 1+1=2"),
    ("jv", "Javanese", "Halo, 1+1=2"),
    ("ko", "Korean (Hangul)", "안녕하세요, 1+1=2"),
    ("fr", "French", "Bonjour, 1+1=2"),
    ("tr", "Turkish", "Merhaba, 1+1=2"),
    ("vi", "Vietnamese (diacritics)", "Xin chào, 1+1=2"),
    ("ur", "Urdu (Arabic-Naskh)", "ہیلو، 1+1=2"),
    ("ta", "Tamil", "வணக்கம், 1+1=2"),
    ("te", "Telugu", "నమస్తే, 1+1=2"),
    ("mr", "Marathi (Devanagari)", "नमस्कार, 1+1=2"),
    ("it", "Italian", "Ciao, 1+1=2"),
    ("th", "Thai", "สวัสดี, ๑+๑=๒"),
    ("pl", "Polish", "Cześć, 1+1=2"),
    ("uk", "Ukrainian (Cyrillic)", "Привіт, 1+1=2"),
    ("fa", "Persian (RTL)", "سلام، ۱+۱=۲"),
    ("nl", "Dutch", "Hallo, 1+1=2"),
    ("he", "Hebrew (RTL)", "שלום, 1+1=2"),
    ("el", "Greek", "Γειά, 1+1=2"),
    ("id", "Indonesian", "Halo, 1+1=2"),
    ("sw", "Swahili", "Habari, 1+1=2"),
    ("emoji", "Emoji + ZWJ + flag", "👋 🇺🇳 👨‍👩‍👧‍👦 1+1=2"),
]


_n = [0]


def step(s):
    print(f"[ime] STEP {s}", flush = True)


def info(s):
    print(f"[ime] {s}", flush = True)


def fail(m):
    raise AssertionError(f"[ime] FAIL: {m}")


def soft_fail(m):
    """Hard fail in STRICT mode, else info-warn (mirrors playwright_chat_ui.py)."""
    if STRICT:
        fail(m)
    info(f"WARN (strict-off): {m}")


with sync_playwright() as p:
    _watchdog = install_wall_clock_watchdog(
        WALL_TIMEOUT_S,
        label = "ime",
        info = info,
    )
    wait_for_health(BASE, timeout = 30.0, info = info)
    browser = p.chromium.launch(
        headless = True,
        args = chromium_launch_args(),
    )
    ctx = browser.new_context(
        viewport = {"width": 1280, "height": 900},
        reduced_motion = "reduce",
    )
    install_view_transition_killer(ctx)
    page = ctx.new_page()
    page.set_default_timeout(60_000)

    page_errors: list[str] = []
    console_errors: list[str] = []
    expected_probe_cancel_500s = [0]

    def _on_console(m):
        if m.type != "error":
            return
        try:
            console_errors.append(m.text)
        except Exception:
            return

    def _attach_listeners(target):
        target.on("pageerror", lambda e: page_errors.append(str(e)))
        target.on("console", _on_console)

    _attach_listeners(page)

    def shoot(name):
        _n[0] += 1
        try:
            page.screenshot(
                path = str(ART / f"{_n[0]:02d}-{name}.png"),
                full_page = True,
                timeout = 90_000,
                animations = "disabled",
            )
        except Exception as _shoot_err:
            info(f"WARN: screenshot {name} failed: {_shoot_err}")

    # 1. Bootstrap auth via /change-password; retry absorbs React form-detach races.
    step("change-password through UI (Setup your account)")
    form_err: Exception | None = None
    for _form_attempt in range(3):
        try:
            page.goto(
                f"{BASE}/change-password",
                wait_until = "domcontentloaded",
                timeout = 60_000,
            )
            try:
                page.wait_for_load_state("networkidle", timeout = 30_000)
            except Exception:
                pass
            pw_field = page.locator("#new-password")
            pw_field.wait_for(state = "visible", timeout = 60_000)
            pw_field.fill(NEW, timeout = 60_000)
            page.fill("#confirm-password", NEW, timeout = 60_000)
            shoot("01-change-password-filled")
            status, _ = click_and_wait_for_response(
                page,
                url_substr = "/api/auth/change-password",
                method = "POST",
                do_click = lambda: page.locator('button[type="submit"]').click(),
                timeout_ms = 30_000,
                info = lambda m: print(f"[ime]   {m}", flush = True),
            )
            if status is not None and status >= 400:
                raise AssertionError(f"change-password POST returned {status}")
            form_err = None
            break
        except Exception as e:
            form_err = e
            info(
                f"change-password attempt {_form_attempt + 1} failed: "
                f"{type(e).__name__}: {str(e)[:200]}"
            )
            if _form_attempt < 2:
                page = recover_or_replace_page(
                    page,
                    ctx,
                    default_timeout_ms = 60_000,
                    info = lambda m: print(f"[ime]   recovery: {m}", flush = True),
                )
                _attach_listeners(page)
    if form_err is not None:
        raise form_err

    # 2. Wait for composer mount (no GGUF; the bug is React state, not inference).
    step("wait for composer to mount")
    try:
        page.wait_for_load_state("networkidle", timeout = 30_000)
    except Exception:
        pass
    composer = page.locator('textarea[aria-label="Message input"]')
    _mount_err: Exception | None = None
    for _mount_attempt in range(2):
        try:
            composer.wait_for(state = "visible", timeout = 60_000)
            _mount_err = None
            break
        except Exception as e:
            _mount_err = e
            info(
                f"composer.wait_for attempt {_mount_attempt + 1} failed: "
                f"{type(e).__name__}: {str(e)[:200]}"
            )
            try:
                shoot(f"02-composer-wait-attempt-{_mount_attempt + 1}-fail")
            except Exception:
                pass
            if _mount_attempt == 0:
                page = recover_or_replace_page(
                    page,
                    ctx,
                    default_timeout_ms = 60_000,
                    info = lambda m: print(f"[ime]   recovery: {m}", flush = True),
                )
                _attach_listeners(page)
                composer = page.locator('textarea[aria-label="Message input"]')
    if _mount_err is not None:
        raise _mount_err
    composer.click()
    shoot("02-composer-focused")

    # Main composer must carry dir="auto" so RTL flows right-to-left.
    dir_attr = composer.evaluate("(el) => el.getAttribute('dir')")
    if dir_attr != "auto":
        soft_fail(
            f'composer is missing dir="auto" (got {dir_attr!r}); RTL ' "languages will render LTR."
        )
    else:
        info('composer dir="auto" present')

    # Source-level guard: grep the unmounted edit/compare composers' JSX for dir="auto".
    _repo_root = Path(__file__).resolve().parents[2]
    _thread_src = (
        _repo_root / "studio/frontend/src/components/assistant-ui/thread.tsx"
    ).read_text()
    _shared_src = (_repo_root / "studio/frontend/src/features/chat/shared-composer.tsx").read_text()
    _edit_idx = _thread_src.find("aui-edit-composer-input")
    if _edit_idx == -1 or 'dir="auto"' not in _thread_src[_edit_idx : _edit_idx + 600]:
        soft_fail('edit composer source is missing dir="auto"')
    else:
        info('edit composer dir="auto" present (source)')
    _compare_idx = _shared_src.find("Send to both models")
    if (
        _compare_idx == -1
        or 'dir="auto"' not in _shared_src[max(_compare_idx - 400, 0) : _compare_idx + 400]
    ):
        soft_fail('compare composer source is missing dir="auto"')
    else:
        info('compare composer dir="auto" present (source)')

    def read_value() -> str:
        return composer.evaluate("(el) => el.value")

    def set_value_via_setter(s: str) -> str:
        """Write via React's setter + paste event, await two rAFs so the controlled
        value commits before readback (plain `.value=s` is overwritten on render)."""
        return composer.evaluate(
            """async (el, v) => {
                const setter = Object.getOwnPropertyDescriptor(
                    window.HTMLTextAreaElement.prototype, 'value'
                ).set;
                setter.call(el, v);
                el.dispatchEvent(new InputEvent('input', {
                    bubbles: true,
                    inputType: 'insertFromPaste',
                    data: v,
                }));
                await new Promise((r) => requestAnimationFrame(r));
                await new Promise((r) => requestAnimationFrame(r));
                return el.value;
            }""",
            s,
        )

    def clear() -> None:
        set_value_via_setter("")

    def restore_idle_composer_after_probe(label: str) -> None:
        """Cancel a real run started by a submit probe before the next case."""
        stop_btn = page.locator('button[aria-label="Stop generating"]')
        send_btn = page.locator('button[aria-label="Send message"]')
        allowed_cancel_500 = False
        try:
            stop_btn.wait_for(state = "visible", timeout = 5_000)
            expected_probe_cancel_500s[0] += 1
            allowed_cancel_500 = True
            stop_btn.click(timeout = 5_000)
            info(f"{label}: stopped generation started by submit probe")
        except Exception:
            if allowed_cancel_500:
                expected_probe_cancel_500s[0] = max(0, expected_probe_cancel_500s[0] - 1)
        try:
            expect(send_btn).to_be_visible(timeout = 15_000)
        except Exception:
            shoot(f"{label}-idle-restore-FAIL")
            fail(
                "Composer did not return to idle after the submit probe; "
                "Send button is unavailable for the next IME regression case."
            )

    # 3. Baseline: ASCII keyboard typing works.
    step("baseline ASCII keyboard typing")
    clear()
    composer.click()
    for ch in "hello world":
        page.keyboard.type(ch)
    got = read_value()
    if got != "hello world":
        fail(f"ASCII typing readback {got!r} != 'hello world'")
    info("baseline ASCII OK")
    shoot("03-baseline-ascii")
    clear()

    # 4. Multilingual paste round-trip; byte-for-byte readback required.
    step(f"multilingual paste round-trip ({len(I18N_SAMPLES)} samples)")
    paste_failures: list[tuple[str, str, str, str]] = []
    for code, label, text in I18N_SAMPLES:
        got = set_value_via_setter(text)
        if got != text:
            paste_failures.append((code, label, text, got))
            info(f"  {code:>6} ({label}): FAIL -- got {got!r}")
        else:
            info(f"  {code:>6} ({label}): OK")
        clear()
    if paste_failures:
        shoot("04-paste-failures")
        lines = [
            f"  {code} ({label}): want={want!r} got={got!r}"
            for code, label, want, got in paste_failures
        ]
        fail(
            f"{len(paste_failures)}/{len(I18N_SAMPLES)} languages failed paste round-trip:\n"
            + "\n".join(lines)
        )
    info(f"all {len(I18N_SAMPLES)} multilingual paste samples OK")
    shoot("04-paste-all-ok")

    # 5. Healthy IME composition (compositionstart/update/end + insert events).
    step("normal IME composition (compose 你好)")
    clear()
    composer.click()
    composer.evaluate(
        """(el) => {
            el.focus();
            el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''}));
            el.dispatchEvent(new CompositionEvent('compositionupdate', {bubbles:true, data:'你'}));
            el.dispatchEvent(new CompositionEvent('compositionupdate', {bubbles:true, data:'你好'}));
            const setter = Object.getOwnPropertyDescriptor(
                window.HTMLTextAreaElement.prototype, 'value'
            ).set;
            setter.call(el, el.value + '你好');
            el.dispatchEvent(new InputEvent('input', {
                bubbles:true, inputType:'insertCompositionText',
                data:'你好', isComposing:true,
            }));
            el.dispatchEvent(new CompositionEvent('compositionend', {bubbles:true, data:'你好'}));
            el.dispatchEvent(new InputEvent('input', {
                bubbles:true, inputType:'insertFromComposition', data:'你好',
            }));
        }"""
    )
    got = read_value()
    if "你好" not in got:
        shoot("05-normal-composition-FAIL")
        fail(f"normal composition readback {got!r} missing '你好'")
    info(f"normal composition OK: ta.value={got!r}")
    shoot("05-normal-composition")
    clear()

    # 6. Stuck IME repro (#5318): duplicate compositionstart wedges
    #    isComposing=true; PR #5327 clears it on non-composing input.
    step("BUG REPRO: stuck IME composition recovery (issue #5318)")
    clear()
    composer.click()
    composer.evaluate(
        """(el) => {
            el.focus();
            el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''}));
            // Duplicate compositionstart with NO matching compositionend.
            // This is exactly the event sequence observed from the IMEs
            // in issue #5318 (kei-yamazaki / langxiaopiao030 / PapyrusNotes).
            el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''}));
        }"""
    )
    # On the broken build React drops 'abcd' and reconciles el.value to ''.
    # wait_for_function crosses the microtask boundary to see committed state.
    page.keyboard.type("abcd")
    try:
        page.wait_for_function(
            """(el) => el.value === 'abcd'""",
            composer.element_handle(),
            timeout = 5_000,
        )
    except Exception:
        pass
    after_key = read_value()
    info(f"after_key='abcd' readback={after_key!r}")
    shoot("06-stuck-composition-recovery")
    if after_key != "abcd":
        fail(
            "stuck-composition repro: keyboard 'abcd' was not preserved after "
            f"duplicate compositionstart; readback {after_key!r}. React state "
            "likely still stuck in isComposing=true (issue #5318 / before "
            "PR #5327)."
        )
    # Cross-check isComposing via the Send button: it stays disabled while
    # isComposing is true (PR #5327).
    send_btn = page.locator('button[aria-label="Send message"]')
    if send_btn.count() == 0:
        soft_fail("Send button not found after stuck-composition recovery")
    else:
        try:
            expect(send_btn).not_to_be_disabled(timeout = 5_000)
            info("Send button correctly enabled after stuck-composition recovery")
        except Exception:
            soft_fail(
                "Send button still disabled after stuck-composition recovery -- "
                "React isComposing state likely never cleared"
            )
    info("stuck-composition recovery PASS")
    clear()

    # 6b. WSL+Chrome repro (#5546): Chrome never emits compositionend after the
    #     IME commit, so the watchdog must release the composing flag once events
    #     go silent.
    step("BUG REPRO: stuck compositionend recovery (issue #5546)")
    clear()
    composer.click()
    composer.evaluate(
        """(el) => {
            el.focus();
            el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''}));
            el.dispatchEvent(new CompositionEvent('compositionupdate', {bubbles:true, data:'你'}));
            el.dispatchEvent(new CompositionEvent('compositionupdate', {bubbles:true, data:'你好'}));
            const setter = Object.getOwnPropertyDescriptor(
                window.HTMLTextAreaElement.prototype, 'value'
            ).set;
            setter.call(el, el.value + '你好');
            el.dispatchEvent(new InputEvent('input', {
                bubbles:true, inputType:'insertCompositionText',
                data:'你好', isComposing:true,
            }));
            // Deliberately omit compositionend; that is the WSL/Chrome
            // bug surface. The watchdog in useImeComposerInputHandlers
            // should reset isComposing after IME_STUCK_TIMEOUT_MS.
        }"""
    )
    send_btn_5546 = page.locator('button[aria-label="Send message"]')
    if send_btn_5546.count() == 0:
        soft_fail("Send button not found for #5546 repro")
    else:
        # Watchdog is 2500ms; allow generous slack for slow CI.
        try:
            expect(send_btn_5546).not_to_be_disabled(timeout = 8_000)
            info("Send button enabled after compositionend never fired")
        except Exception:
            shoot("06b-compositionend-watchdog-FAIL")
            fail(
                "Send button stayed disabled with no compositionend; "
                "watchdog did not release the composing flag (issue #5546)."
            )
    after_value = read_value()
    if "你好" not in after_value:
        soft_fail(f"compositionend-watchdog repro lost committed text: {after_value!r}")
    shoot("06b-compositionend-watchdog")
    info("compositionend watchdog recovery PASS")
    clear()

    # 6c. Watchdog-race repro: after the watchdog clears composingRef, a later
    #     IME keydown (keyCode 229) must not slip preedit text through submit.
    #     The onKeyDown gate re-pins composingRef so handleSubmit refuses.
    step("BUG REPRO: keydown re-pin after watchdog cleared composing (issue #5546 follow-up)")
    clear()
    composer.click()
    composer.evaluate(
        """(el) => {
            el.focus();
            el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''}));
            el.dispatchEvent(new CompositionEvent('compositionupdate', {bubbles:true, data:'半'}));
            const setter = Object.getOwnPropertyDescriptor(
                window.HTMLTextAreaElement.prototype, 'value'
            ).set;
            setter.call(el, el.value + '半角');
            el.dispatchEvent(new InputEvent('input', {
                bubbles:true, inputType:'insertCompositionText',
                data:'半角', isComposing:true,
            }));
        }"""
    )
    send_btn_keydown = page.locator('button[aria-label="Send message"]')
    # Wait past the watchdog so composingRef has cleared.
    try:
        expect(send_btn_keydown).not_to_be_disabled(timeout = 8_000)
    except Exception:
        soft_fail("watchdog did not clear before keydown re-pin test")
    # Fire IME-confirm Enter (keyCode 229) then submit synchronously: the keydown
    # gate re-pins composingRef before handleSubmit, so preedit text is retained.
    submit_probe = composer.evaluate(
        """(el) => {
            el.focus();
            el.dispatchEvent(new KeyboardEvent('keydown', {
                bubbles:true, key:'Enter', code:'Enter', keyCode:229,
                isComposing:true,
            }));
            const form = el.closest('form');
            const before = el.value;
            try { form && form.requestSubmit(); } catch (e) {}
            return {before, after: el.value, cleared: before !== '' && el.value === ''};
        }"""
    )
    if submit_probe.get("cleared"):
        shoot("06c-keydown-repin-FAIL")
        fail(
            "Form submitted after an IME keydown -- preedit text leaked "
            "through the watchdog gap (#5546 follow-up regression)."
        )
    info(f"Form submit refused after IME keydown; textarea retained {submit_probe.get('after')!r}")
    shoot("06c-keydown-repin")
    info("keydown re-pin gate PASS")
    clear()

    # 6d. Keydown re-pin must also re-arm the watchdog: on the WSL+Chrome
    #     stuck-compositionend path no follow-up event arrives, so after re-pin
    #     the watchdog must clear composingRef again or Send re-locks forever.
    step("BUG REPRO: keydown re-pin re-arms watchdog (#5546 follow-up regression)")
    clear()
    composer.click()
    composer.evaluate(
        """(el) => {
            el.focus();
            el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''}));
            el.dispatchEvent(new CompositionEvent('compositionupdate', {bubbles:true, data:'你'}));
            const setter = Object.getOwnPropertyDescriptor(
                window.HTMLTextAreaElement.prototype, 'value'
            ).set;
            setter.call(el, el.value + '你好');
            el.dispatchEvent(new InputEvent('input', {
                bubbles:true, inputType:'insertCompositionText',
                data:'你好', isComposing:true,
            }));
        }"""
    )
    send_btn_rearm = page.locator('button[aria-label="Send message"]')
    # First watchdog cycle: wait for it to clear composingRef.
    try:
        expect(send_btn_rearm).not_to_be_disabled(timeout = 8_000)
    except Exception:
        soft_fail("watchdog did not clear before re-arm test (first cycle)")
    # IME-confirm keydown re-pins composingRef; without the re-arm fix the
    # watchdog never runs again and Send stays blocked forever.
    composer.evaluate(
        """(el) => {
            el.focus();
            el.dispatchEvent(new KeyboardEvent('keydown', {
                bubbles:true, key:'Enter', code:'Enter', keyCode:229,
                isComposing:true,
            }));
        }"""
    )
    # Second watchdog cycle: requestSubmit() after the re-armed window must be
    # allowed; the buggy build stays gated forever.
    rearm_probe = page.evaluate(
        """async (selector) => {
            const ta = document.querySelector(selector);
            const form = ta && ta.closest('form');
            if (!form || !ta) return {ok: false, reason: 'composer missing'};
            const before = ta.value;
            // Wait past the 2500ms watchdog + slack so the re-armed timer
            // fires. If the fix is missing this still resolves but the
            // submit will not flush the textarea.
            await new Promise(r => setTimeout(r, 3500));
            try { form.requestSubmit(); } catch (e) {}
            // Give the submit handler a tick to flush state.
            await new Promise(r => setTimeout(r, 250));
            return {ok: true, before, after: ta.value};
        }""",
        'textarea[aria-label="Message input"]',
    )
    if rearm_probe.get("ok") and rearm_probe.get("after") == rearm_probe.get("before"):
        shoot("06d-keydown-rearm-FAIL")
        fail(
            "After the keydown re-pin the watchdog never re-armed; Send "
            "stayed permanently locked on the WSL+Chrome stuck-end path "
            "(#5546 follow-up regression)."
        )
    info(
        "watchdog re-armed after keydown re-pin: textarea flushed from "
        f"{rearm_probe.get('before')!r} to {rearm_probe.get('after')!r}"
    )
    shoot("06d-keydown-rearm")
    info("keydown re-pin re-arm PASS")
    restore_idle_composer_after_probe("06d-keydown-rearm")
    clear()

    # 6e. Mac input-method switch - onKeyDown immediate recovery.
    #     A Mac IME switch fires compositionstart but never compositionend; the
    #     first English keydown (isComposing=false) must clear composingRef via
    #     the onKeyDown else-if branch, before the 2500ms watchdog fires.
    #     To isolate that path (not onChange) we dispatch a synthetic keydown with
    #     NO follow-up input event, so onChange never fires.
    step("BUG REPRO: Mac IME switch - onKeyDown immediate recovery")
    clear()
    composer.click()
    # Seed sendable content so Send's state reflects composition only, not
    # empty-content gating (insertFromPaste leaves composingRef false).
    set_value_via_setter("hello")
    # Switch TO Chinese: compositionstart fires but compositionend never arrives.
    composer.evaluate(
        """(el) => {
            el.focus();
            el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''}));
        }"""
    )
    # Let React process compositionstart and update isComposing.
    page.wait_for_timeout(200)
    send_btn_mac_kd = page.locator('button[aria-label="Send message"]')
    # Dispatch ONLY a keydown (no input event) so onChange never fires and the
    # onKeyDown else-if branch is the only path that can clear composingRef.
    # page.keyboard.type() would fire onChange too and mask a regression.
    composer.evaluate(
        """(el) => {
            el.focus();
            el.dispatchEvent(new KeyboardEvent('keydown', {
                bubbles: true, key: 'a', code: 'KeyA', keyCode: 65,
                isComposing: false,
            }));
        }"""
    )
    if send_btn_mac_kd.count() == 0:
        soft_fail("Send button not found for Mac IME switch (onKeyDown) repro")
    else:
        try:
            # 1500ms < 2500ms watchdog: only the onKeyDown else-if path can
            # clear composingRef this quickly.
            expect(send_btn_mac_kd).not_to_be_disabled(timeout = 1_500)
            info(
                "Send button enabled within 1500ms after Mac IME switch + "
                "English keydown (onKeyDown else-if branch fired, not watchdog)"
            )
        except Exception:
            shoot("06e-mac-ime-keydown-FAIL")
            fail(
                "Send button stayed disabled after Mac input-method switch + "
                "English keydown; the onKeyDown else-if branch did not clear "
                "composingRef immediately (expected recovery in < 1500ms)."
            )
    shoot("06e-mac-ime-keydown")
    info("Mac IME switch onKeyDown recovery PASS")
    clear()

    # 6f. Candidate-confirming Enter must not unblock submit.
    #     Some IMEs report it as isComposing=false/keyCode=13 while composingRef
    #     is still pinned; it must be swallowed and keep Send disabled, else it
    #     submits before the candidate is committed.
    step("BUG REPRO: Mac IME switch - Enter must not unblock submit")
    clear()
    composer.click()
    set_value_via_setter("hello")
    composer.evaluate(
        """(el) => {
            el.focus();
            el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''}));
        }"""
    )
    page.wait_for_timeout(200)
    send_btn_mac_enter = page.locator('button[aria-label="Send message"]')
    composer.evaluate(
        """(el) => {
            el.focus();
            el.dispatchEvent(new KeyboardEvent('keydown', {
                bubbles: true, cancelable: true, key: 'Enter', code: 'Enter',
                keyCode: 13, isComposing: false,
            }));
        }"""
    )
    try:
        expect(send_btn_mac_enter).to_be_disabled(timeout = 500)
        info("Enter while composingRef is stuck kept Send disabled")
    except Exception:
        shoot("06f-mac-ime-enter-guard-FAIL")
        fail(
            "Enter cleared a stuck composingRef immediately; candidate-confirming "
            "Enter can fall through to submit."
        )
    composer.evaluate(
        """(el) => {
            el.focus();
            el.dispatchEvent(new KeyboardEvent('keydown', {
                bubbles: true, key: 'a', code: 'KeyA', keyCode: 65,
                isComposing: false,
            }));
        }"""
    )
    try:
        expect(send_btn_mac_enter).not_to_be_disabled(timeout = 1_500)
        info("non-Enter key after Enter guard still recovers immediately")
    except Exception:
        shoot("06f-mac-ime-enter-guard-recovery-FAIL")
        fail("After guarding Enter, a later non-IME key did not clear composingRef immediately.")
    shoot("06f-mac-ime-enter-guard")
    info("Mac IME switch Enter guard PASS")
    clear()

    # 6g. Mac input-method switch - onBlur immediate recovery.
    #     Some Mac IME switches steal textarea focus; onBlur resets composingRef
    #     unconditionally. Safe because the OS commits/cancels composition before
    #     surrendering focus, so blur is a reliable reset point.
    step("BUG REPRO: Mac IME switch - onBlur immediate recovery")
    clear()
    composer.click()
    # Seed sendable content so Send's state reflects composition only.
    set_value_via_setter("hello")
    # Switch TO Chinese: compositionstart fires, compositionend never comes.
    composer.evaluate(
        """(el) => {
            el.focus();
            el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''}));
        }"""
    )
    page.wait_for_timeout(200)
    send_btn_mac_blur = page.locator('button[aria-label="Send message"]')
    # Blur to simulate the OS stealing focus during an IME switch.
    composer.evaluate("(el) => el.blur()")
    # onBlur clears composition; re-focus so React renders the updated Send state.
    composer.click()
    if send_btn_mac_blur.count() == 0:
        soft_fail("Send button not found for Mac IME switch (onBlur) repro")
    else:
        try:
            # 1500ms < 2500ms watchdog: only onBlur can clear composingRef this
            # quickly when no keydown is fired.
            expect(send_btn_mac_blur).not_to_be_disabled(timeout = 1_500)
            info(
                "Send button enabled within 1500ms after Mac IME switch + "
                "textarea blur (onBlur handler fired, not watchdog)"
            )
        except Exception:
            shoot("06f-mac-ime-blur-FAIL")
            fail(
                "Send button stayed disabled after Mac input-method switch + "
                "textarea blur; the onBlur handler did not reset composingRef "
                "(expected recovery in < 1500ms)."
            )
    shoot("06g-mac-ime-blur")
    info("Mac IME switch onBlur recovery PASS")
    clear()

    # 7. Final state: filter benign 401 noise via is_benign_*; fail on real errors.
    shoot("07-final")
    real_page_errors = [e for e in page_errors if not is_benign_page_error(e)]
    probe_cancel_500_allowance = expected_probe_cancel_500s[0]
    real_console_errors = []
    for error in console_errors:
        if is_benign_console_error(error):
            continue
        if (
            probe_cancel_500_allowance > 0
            and "Failed to load resource: the server responded with a status of 500" in error
            and "Internal Server Error" in error
        ):
            probe_cancel_500_allowance -= 1
            continue
        real_console_errors.append(error)
    info(
        f"page_errors={len(page_errors)} ({len(real_page_errors)} non-benign); "
        f"console_errors={len(console_errors)} "
        f"({len(real_console_errors)} non-benign)"
    )
    if page_errors:
        info(f"first page error: {page_errors[0][:200]!r}")
    if console_errors:
        info(f"first console error: {console_errors[0][:200]!r}")
    if real_page_errors:
        fail(
            f"{len(real_page_errors)} non-benign pageerror events; "
            f"first={real_page_errors[0][:200]!r}"
        )
    if real_console_errors:
        fail(
            f"{len(real_console_errors)} non-benign console.error events; "
            f"first={real_console_errors[0][:200]!r}"
        )

    info(
        f"DONE: ascii=OK paste={len(I18N_SAMPLES)}/{len(I18N_SAMPLES)} "
        f"normal_composition=OK stuck_recovery=OK "
        f"compositionend_watchdog=OK keydown_repin=OK "
        f"keydown_repin_rearm=OK "
        f"mac_ime_switch_keydown=OK mac_ime_switch_enter_guard=OK "
        f"mac_ime_switch_blur=OK"
    )
    _watchdog.cancel()
    browser.close()
