Opus found the exact-match signature fails open in the COMMON case: the server's last persisted draft is often a PREFIX of the submitted text (Enter-to-send within 400ms cancels the pending debounced save), so matching only the submitted text misses it and the prefix restores (the literal 'tail of the already-submitted message' symptom). Root fix (Opus-recommended): capture BOTH signatures at send — the remembered SERVER-draft signature (_composerDraftPayloadSignatureForSid, read before the reset) AND the submitted-payload signature — and suppress a restore matching EITHER. The prefix IS the remembered server draft; the remembered files went through the same lossy serialization so they self-match; a genuinely new cross-tab draft matches neither and restores. Subsumes the file-canonicalization fix (kept as belt-and-suspenders). Suppression stores signatures[] not signature. Regression tests: stale-prefix suppressed (Opus Finding 1), file round-trip self-matches (Codex Finding 2), cross-tab-different still restores. Test harness updated to model send-time server draft vs later restore draft as distinct. Co-authored-by: ruizanthony <ruizanthony@users.noreply.github.com>
235 lines
8.7 KiB
Python
235 lines
8.7 KiB
Python
"""Regression tests for #5060: preserve typed composer input during boot restore."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
|
NODE = shutil.which("node")
|
|
|
|
|
|
def _function_block(source: str, marker: str) -> str:
|
|
start = source.index(marker)
|
|
signature_end = source.index(") {", start)
|
|
brace = source.index("{", signature_end)
|
|
depth = 1
|
|
idx = brace + 1
|
|
while depth:
|
|
char = source[idx]
|
|
if char == "{":
|
|
depth += 1
|
|
elif char == "}":
|
|
depth -= 1
|
|
idx += 1
|
|
return source[start:idx]
|
|
|
|
|
|
def _draft_restore_block() -> str:
|
|
start_marker = "// Restore server-persisted composer draft (synced across clients + survives refresh)."
|
|
end_marker = "// Clear the in-flight session marker now that this load has completed (#1060)."
|
|
start = SESSIONS_JS.index(start_marker)
|
|
end = SESSIONS_JS.index(end_marker, start)
|
|
return SESSIONS_JS[start:end]
|
|
|
|
|
|
def _draft_restore_suppression_block() -> str:
|
|
start = SESSIONS_JS.index("const _composerDraftRestoreSuppressedUntilBySid")
|
|
end = SESSIONS_JS.index("function _profileMatchesActiveProfile", start)
|
|
return SESSIONS_JS[start:end]
|
|
|
|
|
|
def _run_case(*, initial_text: str, draft: dict | None, opts: dict | None, current_sid, force_reload: bool, suppress_restore: bool | dict = False, remembered_server_draft: dict | None = None) -> dict:
|
|
if NODE is None:
|
|
pytest.skip("node not on PATH")
|
|
restore_fn = _function_block(SESSIONS_JS, "function _restoreComposerDraft(draft, targetSid, opts={}) {")
|
|
draft_block = _draft_restore_block()
|
|
suppression_block = _draft_restore_suppression_block()
|
|
if isinstance(suppress_restore, dict):
|
|
suppress_call = (
|
|
"_suppressComposerDraftRestoreAfterSubmit("
|
|
f"sid, {json.dumps(suppress_restore.get('text', ''))}, {json.dumps(suppress_restore.get('files', []))}"
|
|
");"
|
|
)
|
|
elif suppress_restore:
|
|
suppress_call = "_suppressComposerDraftRestoreAfterSubmit(sid);"
|
|
else:
|
|
suppress_call = ""
|
|
# At SEND time the session's composer_draft holds what was last persisted to
|
|
# the server (the prefix/old draft), which the dual-signature suppression
|
|
# reads. That is DISTINCT from the draft a later stale/cross-tab poll echoes
|
|
# back for restore. Seed the send-time remembered draft separately, then swap
|
|
# in the restore draft before _restoreComposerDraft runs.
|
|
send_time_draft = remembered_server_draft if remembered_server_draft is not None else draft
|
|
script = f"""
|
|
const state = {{
|
|
value: {json.dumps(initial_text)},
|
|
autoResizeCount: 0,
|
|
updateSendBtnCount: 0,
|
|
}};
|
|
function $() {{
|
|
return state;
|
|
}}
|
|
function autoResize() {{
|
|
state.autoResizeCount += 1;
|
|
}}
|
|
function updateSendBtn() {{
|
|
state.updateSendBtnCount += 1;
|
|
}}
|
|
let _loadingSessionId = null;
|
|
const sid = 'boot-session';
|
|
const S = {{
|
|
session: {{
|
|
session_id: sid,
|
|
composer_draft: {json.dumps(send_time_draft)},
|
|
}},
|
|
}};
|
|
const currentSid = {json.dumps(current_sid)};
|
|
const forceReload = {json.dumps(force_reload)};
|
|
const opts = {json.dumps(opts or {})};
|
|
function _composerDraftHasPayload(text, files) {{
|
|
return !!(String(text || '') || (Array.isArray(files) && files.filter(Boolean).length));
|
|
}}
|
|
function _rememberComposerDraftPayloadState(sid, text, files) {{
|
|
state.rememberedDraft = {{sid, text, files}};
|
|
if (S.session && S.session.session_id === sid) {{
|
|
S.session.composer_draft = {{text, files}};
|
|
}}
|
|
}}
|
|
{suppression_block}
|
|
{suppress_call}
|
|
// After send-time suppression is captured, the stale/cross-tab poll delivers a
|
|
// (possibly different) draft that restore will evaluate.
|
|
S.session.composer_draft = {json.dumps(draft)};
|
|
{restore_fn}
|
|
{draft_block}
|
|
process.stdout.write(JSON.stringify({{
|
|
value: state.value,
|
|
autoResizeCount: state.autoResizeCount,
|
|
updateSendBtnCount: state.updateSendBtnCount,
|
|
}}));
|
|
"""
|
|
result = subprocess.run([NODE, "-e", script], capture_output=True, text=True, timeout=20)
|
|
assert result.returncode == 0, result.stderr
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_boot_restore_preserves_typed_input_against_remote_draft():
|
|
"""Boot restore should keep local typed text when the saved draft arrives later."""
|
|
data = _run_case(
|
|
initial_text="typed locally",
|
|
draft={"text": "server draft", "files": []},
|
|
opts={"preserveActiveInput": True},
|
|
current_sid=None,
|
|
force_reload=False,
|
|
)
|
|
assert data["value"] == "typed locally"
|
|
assert data["autoResizeCount"] == 0
|
|
assert data["updateSendBtnCount"] == 0
|
|
|
|
|
|
def test_boot_restore_preserves_typed_input_when_saved_draft_is_empty():
|
|
"""Boot restore should keep local typed text even when the restored draft is empty."""
|
|
data = _run_case(
|
|
initial_text="typed locally",
|
|
draft={"text": "", "files": []},
|
|
opts={"preserveActiveInput": True},
|
|
current_sid=None,
|
|
force_reload=False,
|
|
)
|
|
assert data["value"] == "typed locally"
|
|
assert data["autoResizeCount"] == 0
|
|
assert data["updateSendBtnCount"] == 0
|
|
|
|
|
|
def test_boot_restore_still_populates_empty_composer_from_saved_draft():
|
|
"""A blank composer should still take the server draft during boot restore."""
|
|
data = _run_case(
|
|
initial_text="",
|
|
draft={"text": "server draft", "files": []},
|
|
opts={"preserveActiveInput": True},
|
|
current_sid=None,
|
|
force_reload=False,
|
|
)
|
|
assert data["value"] == "server draft"
|
|
assert data["autoResizeCount"] == 1
|
|
assert data["updateSendBtnCount"] == 1
|
|
|
|
|
|
def test_same_session_submitted_clear_blocks_stale_server_draft_restore():
|
|
"""After send clears the composer, a stale same-session refresh must not refill it.
|
|
|
|
Mobile browsers can deliver refresh/input timing such that the textarea is
|
|
empty locally while /api/session still returns the previous non-empty
|
|
composer_draft. The submitted-clear suppression should keep that old draft
|
|
out of the input.
|
|
"""
|
|
data = _run_case(
|
|
initial_text="",
|
|
draft={"text": "old submitted suffix", "files": []},
|
|
opts={"preserveActiveInput": True},
|
|
current_sid="boot-session",
|
|
force_reload=True,
|
|
remembered_server_draft={"text": "old submitted suffix", "files": []},
|
|
suppress_restore={"text": "old submitted suffix", "files": []},
|
|
)
|
|
assert data["value"] == ""
|
|
assert data["autoResizeCount"] == 0
|
|
assert data["updateSendBtnCount"] == 0
|
|
|
|
|
|
def test_same_session_submitted_clear_blocks_stale_prefix_server_draft_restore():
|
|
"""#5471 (Opus Finding 1): the server's last persisted draft is often a PREFIX
|
|
of the submitted text (Enter-to-send within 400ms of a keystroke cancels the
|
|
pending debounced save), so an exact-match on the submitted text would miss it.
|
|
The dual signature also captures the remembered SERVER draft, so the stale
|
|
prefix is suppressed.
|
|
"""
|
|
data = _run_case(
|
|
initial_text="",
|
|
draft={"text": "hello worl", "files": []}, # stale prefix echoed back
|
|
opts={"preserveActiveInput": True},
|
|
current_sid="boot-session",
|
|
force_reload=True,
|
|
remembered_server_draft={"text": "hello worl", "files": []}, # server had the prefix
|
|
suppress_restore={"text": "hello world!", "files": []}, # user sent the full text
|
|
)
|
|
assert data["value"] == "", "stale prefix of the just-sent text must be suppressed"
|
|
assert data["autoResizeCount"] == 0
|
|
assert data["updateSendBtnCount"] == 0
|
|
|
|
|
|
def test_same_session_submitted_clear_allows_different_cross_tab_draft_restore():
|
|
"""A different same-session draft saved by another tab must not be hidden by send-time suppression."""
|
|
data = _run_case(
|
|
initial_text="",
|
|
draft={"text": "new cross-tab draft", "files": []},
|
|
opts={"preserveActiveInput": True},
|
|
current_sid="boot-session",
|
|
force_reload=True,
|
|
remembered_server_draft={"text": "old submitted suffix", "files": []},
|
|
suppress_restore={"text": "old submitted suffix", "files": []},
|
|
)
|
|
assert data["value"] == "new cross-tab draft"
|
|
assert data["autoResizeCount"] == 1
|
|
assert data["updateSendBtnCount"] == 1
|
|
|
|
|
|
def test_cross_session_restore_keeps_existing_draft_semantics():
|
|
"""Ordinary cross-session loads should still restore the target session draft."""
|
|
data = _run_case(
|
|
initial_text="typed locally",
|
|
draft={"text": "server draft", "files": []},
|
|
opts={},
|
|
current_sid="other-session",
|
|
force_reload=False,
|
|
)
|
|
assert data["value"] == "server draft"
|
|
assert data["autoResizeCount"] == 1
|
|
assert data["updateSendBtnCount"] == 1
|