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>
This commit is contained in:
mwiegand 2026-05-17 02:15:51 +02:00
parent 42bdc6ad98
commit 19bc0afaa9
No known key found for this signature in database
4 changed files with 118 additions and 12 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,2 +1,2 @@
a2cab604c51a916119ec71f15f948c3304bf54c39bbb7b36e5515891c7849484 editor.bundle.js 6700b694fe25837f52e77c780d88f3eb5aef2a1591dc461c26efa3fa9724290b editor.bundle.js
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 editor.bundle.css e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 editor.bundle.css

View file

@ -2,7 +2,7 @@ import { EditorState, Compartment } from "@codemirror/state";
import { EditorView, keymap, lineNumbers, highlightActiveLine } from "@codemirror/view"; import { EditorView, keymap, lineNumbers, highlightActiveLine } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { StreamLanguage, indentOnInput, bracketMatching, defaultHighlightStyle, syntaxHighlighting } from "@codemirror/language"; import { StreamLanguage, indentOnInput, bracketMatching, defaultHighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { closeBrackets, closeBracketsKeymap, completionKeymap } from "@codemirror/autocomplete"; import { closeBrackets, closeBracketsKeymap, completionKeymap, acceptCompletion } from "@codemirror/autocomplete";
import { shell as shellLegacy } from "@codemirror/legacy-modes/mode/shell"; import { shell as shellLegacy } from "@codemirror/legacy-modes/mode/shell";
import { srccfgLanguage } from "./srccfg-mode.js"; import { srccfgLanguage } from "./srccfg-mode.js";
@ -42,6 +42,11 @@ function mount(textarea, { language = "plain", vocab = null } = {}) {
langCompartment.of(lang ? [lang] : []), langCompartment.of(lang ? [lang] : []),
autocompleteCompartment.of(vocab ? [autocompleteExtension(vocab)] : []), autocompleteCompartment.of(vocab ? [autocompleteExtension(vocab)] : []),
keymap.of([ keymap.of([
// Tab → acceptCompletion when the popup is open; falls through
// to indentWithTab when no popup. `run` returning false means
// cm6 keeps walking the keymap list, so indentWithTab still
// works as a fallback indent on Tab when typing normally.
{ key: "Tab", run: acceptCompletion },
...closeBracketsKeymap, ...closeBracketsKeymap,
...defaultKeymap, ...defaultKeymap,
...historyKeymap, ...historyKeymap,

View file

@ -0,0 +1,101 @@
"""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}"