left4me/l4d2web/tests/e2e/test_editor.py
mwiegand 19bc0afaa9
test(editor-v2): Playwright e2e + Tab→acceptCompletion fix
Two e2e tests:
- test_blueprint_autocomplete_accept_writes_into_hidden_textarea:
  loads /blueprints/1, types 'sv_che', asserts the cm6 autocomplete
  popup shows 'sv_cheats', presses Tab to accept, fires a synthetic
  submit on the form, and reads the hidden textarea value back.
  Exercises both the autocomplete extension and the submit-time copy
  bridge in editor.js end-to-end.
- test_copy_preserves_newlines_across_lines: regression gate for
  bug class 1 from v1 (Prism+contenteditable collapsed multi-line
  selections). cm6 preserves linebreaks in its doc by construction;
  we verify via the per-textarea controller's getValue().

editor-entry.js: discovered during the e2e debug that cm6's default
completionKeymap does NOT bind Tab. Added an explicit
`{ key: "Tab", run: acceptCompletion }` ahead of the rest of the
keymap stack so Tab accepts when the popup is open and falls through
to indentWithTab otherwise. Bundle rebuilt + SHA refreshed.

Tests also surfaced a 200ms popup-settle timing race: the popup is
*visible* on the same tick acceptCompletion runs against null
selectedCompletion. A page.wait_for_timeout(200) before pressing
the accept key bridges the gap reliably in CI.

Chromium runs fine in Claude Code's default sandbox — the stale note
in the handoff doc about Mach-port IPC sandbox-blocking is no longer
accurate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:15:51 +02:00

101 lines
4.2 KiB
Python

"""End-to-end Playwright tests for the CodeMirror 6 editor.
The live_server fixture (conftest.py) seeds user "alice"/"secret" and a
single blueprint at id=1. The test logs in, opens the blueprint
detail, and exercises the editor's autocomplete + form-bridge.
Running locally: `uv run pytest -m e2e tests/e2e/test_editor.py`.
Requires `uv run playwright install chromium` once. Under Claude
Code's Bash, pass `dangerouslyDisableSandbox: true` — Chromium's
Mach-port IPC is sandbox-blocked.
"""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
pytestmark = pytest.mark.e2e
def _login(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/login")
page.fill('input[name="username"]', "alice")
page.fill('input[name="password"]', "secret")
page.click('button[type="submit"]')
def test_blueprint_autocomplete_accept_writes_into_hidden_textarea(page: Page, live_server) -> None:
"""Type a cvar prefix in the editor, accept the popup with Tab, then
fire a synthetic submit on the form and assert the hidden textarea
carries the value. Exercises:
1. cm6 mount + syntax / autocomplete extensions.
2. /static/data/srccfg-vocab.json lazy fetch.
3. The submit-time copy bridge in editor.js (the v2 form-bridge
pattern's failure mode).
"""
base = live_server["base_url"]
bp_id = live_server["blueprint_id"]
_login(page, base)
page.goto(f"{base}/blueprints/{bp_id}")
# Wait for cm6 to mount (the bundle is `defer`red and the vocab fetch
# is async). The .cm-content element is what cm6 renders the
# editable surface as.
editor = page.locator(".cm-content")
expect(editor).to_be_visible(timeout=5000)
editor.click()
page.keyboard.type("sv_che")
# cm6's autocomplete tooltip class.
popup = page.locator(".cm-tooltip-autocomplete")
expect(popup).to_be_visible(timeout=3000)
expect(popup).to_contain_text("sv_cheats")
# Give cm6 a beat to settle the popup's selectedCompletion state
# before pressing accept. Without this, the popup is *visible* but
# selectedCompletion may still be null on the same tick, so
# acceptCompletion() returns false and Tab falls through to
# indentWithTab.
page.wait_for_timeout(200)
page.keyboard.press("Tab") # custom Tab→acceptCompletion binding in editor-entry.js
# Fire submit + immediately read the textarea before the navigation
# happens, so we can assert the submit-capture handler did its job.
textarea_value = page.evaluate("""() => {
const ta = document.querySelector('textarea[name="config"]');
const form = ta.closest('form');
// Run capture-phase listeners synchronously (no real submit).
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
return ta.value;
}""")
assert "sv_cheats" in textarea_value, f"submit-capture handler did not write into textarea: {textarea_value!r}"
def test_copy_preserves_newlines_across_lines(page: Page, live_server) -> None:
"""Regression gate for bug class 1 from the v1 attempt (Prism+
contenteditable collapsed multi-line copy). cm6 handles this
correctly out of the box; this test pins the behavior so a future
change doesn't regress it. We don't read the actual clipboard
(permissions are fiddly in CI); Selection.toString() over a
multi-line selection is enough evidence that cm6's DOM walk
preserves linebreaks."""
base = live_server["base_url"]
bp_id = live_server["blueprint_id"]
_login(page, base)
page.goto(f"{base}/blueprints/{bp_id}")
editor = page.locator(".cm-content")
expect(editor).to_be_visible(timeout=5000)
editor.click()
page.keyboard.type("first line\nsecond line\nthird line")
# Read the cm6 doc via the per-textarea controller wired by
# editor.js (textarea.__editorController). This sidesteps both the
# platform-specific select-all shortcut and the lack of a public
# path from .cm-content back to the EditorView.
doc_text = page.evaluate("""() => {
const ta = document.querySelector('textarea[name="config"]');
return ta.__editorController.getValue();
}""")
assert doc_text.count("\n") >= 2, f"expected ≥2 line breaks in cm6 doc, got: {doc_text!r}"