Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions panel/models/ace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,16 @@ export class AcePlotView extends HTMLBoxView {
_update_language(): void {
if (this.model.language != null) {
this._editor.session.setMode(`ace/mode/${this.model.language}`)
// Re-apply annotations after mode change, since setMode may
// spawn a new worker that would overwrite user annotations.
this._add_annotations()
}
}

_add_annotations(): void {
// Toggle the Ace worker BEFORE setting annotations, because
// disabling the worker clears the session's annotations.
this._editor.session.setUseWorker(this.model.annotations.length === 0)
this._editor.session.setAnnotations(this.model.annotations)
}

Expand Down
216 changes: 216 additions & 0 deletions panel/tests/ui/widgets/test_codeeditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,219 @@ def test_code_editor_not_on_keyup(page):
page.keyboard.up(ctrl_key)

wait_until(lambda: editor.value == "print(\"Hello UI!\")", page)


def _find_ace_editor_js():
"""Return JS snippet that locates the Ace editor through shadow DOM."""
return """
function findAceEditor(root) {
const elements = root.querySelectorAll('*');
for (const el of elements) {
if (el.shadowRoot) {
const ed = el.shadowRoot.querySelector('.ace_editor');
if (ed && ed.env && ed.env.editor) return ed.env.editor;
const nested = findAceEditor(el.shadowRoot);
if (nested) return nested;
}
}
return null;
}
"""


def _get_ace_annotations(page):
"""Get annotations from the Ace editor session via JS evaluation."""
return page.evaluate(f"""() => {{
{_find_ace_editor_js()}
const editor = findAceEditor(document);
if (!editor) return [];
return editor.session.getAnnotations();
}}""")


def _get_ace_use_worker(page):
"""Get whether the Ace session worker is enabled."""
return page.evaluate(f"""() => {{
{_find_ace_editor_js()}
const editor = findAceEditor(document);
if (!editor) return null;
return editor.session.getOption('useWorker');
}}""")


def _assert_annotations_stable(page, expected_count, checks=5, interval=200):
"""Assert annotations stay at expected_count over multiple checkpoints.

More robust than a single hardcoded sleep — verifies persistence across
multiple intervals, catching any delayed worker overwrites.
"""
for _ in range(checks):
page.wait_for_timeout(interval)
assert len(_get_ace_annotations(page)) == expected_count


def test_code_editor_annotations(page):
"""Test that user-set annotations are not overwritten by Ace's worker."""
code = "test:\n- {a: 1, b: 2}\n- {c: 3, d: 4}\n"
editor = CodeEditor(value=code, language="yaml", annotations=[])

serve_component(page, editor)
ace_input = page.locator(".ace_content")
expect(ace_input).to_have_count(1)

# Worker should be enabled initially (no user annotations)
wait_until(lambda: _get_ace_use_worker(page) is True, page)

# Set user annotations
editor.annotations = [
{"row": 1, "column": 0, "text": "a warning", "type": "warning"},
{"row": 2, "column": 0, "text": "an error", "type": "error"},
]

# Wait for annotations to sync and worker to be disabled
wait_until(lambda: len(_get_ace_annotations(page)) == 2, page)
wait_until(lambda: _get_ace_use_worker(page) is False, page)

# Verify annotations persist across multiple checkpoints
# (worker would overwrite within ~200ms if still active)
_assert_annotations_stable(page, expected_count=2)
annotations = _get_ace_annotations(page)
assert annotations[0]["type"] == "warning"
assert annotations[1]["type"] == "error"

# Clear annotations — worker should re-enable
editor.annotations = []
wait_until(lambda: len(_get_ace_annotations(page)) == 0, page)
wait_until(lambda: _get_ace_use_worker(page) is True, page)


def test_code_editor_annotations_constructor(page):
"""Test that annotations provided at construction time are applied."""
code = "test:\n- {a: 1}\n"
annotations = [{"row": 1, "column": 0, "text": "note", "type": "info"}]
editor = CodeEditor(value=code, language="yaml", annotations=annotations)

serve_component(page, editor)
expect(page.locator(".ace_content")).to_have_count(1)

# Worker should be disabled (user annotations set at construction)
wait_until(lambda: _get_ace_use_worker(page) is False, page)

# Annotations set at construction should survive across checkpoints
_assert_annotations_stable(page, expected_count=1)
result = _get_ace_annotations(page)
assert result[0]["type"] == "info"
assert result[0]["text"] == "note"


def test_code_editor_annotations_persist_on_language_change(page):
"""Test that annotations persist when the language/mode is changed."""
code = "x = 1\ny = 2\n"
editor = CodeEditor(value=code, language="python", annotations=[])

serve_component(page, editor)
expect(page.locator(".ace_content")).to_have_count(1)

# Set user annotations
editor.annotations = [
{"row": 0, "column": 0, "text": "check this", "type": "warning"},
]
wait_until(lambda: len(_get_ace_annotations(page)) == 1, page)

# Change the language — triggers setMode() which spawns a new worker
editor.language = "yaml"

# Annotations should survive the language change and worker respawn
_assert_annotations_stable(page, expected_count=1)
annotations = _get_ace_annotations(page)
assert annotations[0]["type"] == "warning"
assert annotations[0]["text"] == "check this"
# Worker should still be disabled after language change
assert _get_ace_use_worker(page) is False


def test_code_editor_annotations_replacement(page):
"""Test that replacing annotations (set A -> set B) works correctly."""
code = "line1\nline2\nline3\n"
editor = CodeEditor(value=code, language="text", annotations=[])

serve_component(page, editor)
expect(page.locator(".ace_content")).to_have_count(1)

# Set initial annotations (set A)
editor.annotations = [
{"row": 0, "column": 0, "text": "first warning", "type": "warning"},
]
wait_until(lambda: len(_get_ace_annotations(page)) == 1, page)
assert _get_ace_annotations(page)[0]["text"] == "first warning"

# Replace with different annotations (set B)
editor.annotations = [
{"row": 1, "column": 0, "text": "error here", "type": "error"},
{"row": 2, "column": 0, "text": "also here", "type": "error"},
]
wait_until(lambda: len(_get_ace_annotations(page)) == 2, page)
annotations = _get_ace_annotations(page)
assert annotations[0]["text"] == "error here"
assert annotations[1]["text"] == "also here"


def test_code_editor_annotations_persist_on_value_change(page):
"""Test that annotations persist when the editor value is changed."""
code = "x = 1\n"
editor = CodeEditor(value=code, language="python", annotations=[])

serve_component(page, editor)
expect(page.locator(".ace_content")).to_have_count(1)

# Set user annotations
editor.annotations = [
{"row": 0, "column": 0, "text": "flagged", "type": "error"},
]
wait_until(lambda: len(_get_ace_annotations(page)) == 1, page)

# Change the code programmatically
editor.value = "y = 2\nz = 3\n"
wait_until(lambda: "y = 2" in page.locator(".ace_content").inner_text(), page)

# Annotations should still be present after value change
_assert_annotations_stable(page, expected_count=1)
assert _get_ace_annotations(page)[0]["text"] == "flagged"


def test_code_editor_worker_resumes_after_clear(page):
"""Test that Ace's syntax worker resumes producing annotations after clear.

Full lifecycle: worker active -> user annotations (worker off) ->
clear (worker back on) -> worker produces its own annotations.
Uses intentionally invalid JavaScript to trigger Ace's JS worker.
"""
# Invalid JS that Ace's worker will flag with syntax errors
invalid_js = "function foo( {\n return\n}\n"
editor = CodeEditor(value=invalid_js, language="javascript", annotations=[])

serve_component(page, editor)
expect(page.locator(".ace_content")).to_have_count(1)

# Step 1: Worker should be active and produce annotations on invalid JS
wait_until(lambda: len(_get_ace_annotations(page)) > 0, page)
worker_annotations = _get_ace_annotations(page)
assert any(a["type"] == "error" for a in worker_annotations)

# Step 2: Set user annotations — worker should be disabled
editor.annotations = [
{"row": 0, "column": 0, "text": "user note", "type": "info"},
]
wait_until(lambda: _get_ace_use_worker(page) is False, page)
wait_until(lambda: len(_get_ace_annotations(page)) == 1, page)
assert _get_ace_annotations(page)[0]["text"] == "user note"

# Step 3: Clear user annotations — worker should re-enable and
# produce its own annotations on the still-invalid JS code
editor.annotations = []
wait_until(lambda: _get_ace_use_worker(page) is True, page)
wait_until(lambda: len(_get_ace_annotations(page)) > 0, page)
resumed_annotations = _get_ace_annotations(page)
assert any(a["type"] == "error" for a in resumed_annotations)
# Verify these are worker-produced, not our cleared user annotations
assert all(a["text"] != "user note" for a in resumed_annotations)
Loading