Files
2026-07-13 13:12:00 +08:00

420 lines
18 KiB
Python

"""E2E: commenting on a *rendered* HTML file (the bridge path).
HTML files open in the sandboxed preview iframe (opaque origin, no
``allow-same-origin``), so the host app cannot read the frame's selection
directly. ``HtmlCommentViewer`` injects a small bridge script into the frame
that relays selections over a private MessageChannel; the parent then drives
the same CommentsPanel + comment store used by Markdown and code. This test
pins that round-trip end to end:
1. An ``.html`` file is seeded via the filesystem resources API (no agent
run), with a sentence that appears exactly once — verbatim in both the
rendered text and the raw source — so the stored offset is deterministic.
2. The FileViewer opens the file in the preview iframe (the HTML default).
3. The user selects that sentence *inside the sandboxed frame*; the bridge
relays it and the floating "Add comment" button (portalled to the parent
document) appears.
4. Clicking it opens the CommentsPanel with the selection as the pending
anchor; the user fills the body and saves.
5. Via the REST API, the stored comment carries the selected sentence as its
``anchor_content`` at the offset matching the raw HTML source — so the
agent (which edits the source) can locate it.
If this goes red, the regression is most likely in the bridge handshake or the
selection relay: iframe load → MessageChannel init → ``omni:selection`` →
parent offset resolution → ``onSetActiveSelection`` → add-comment POST.
"""
from __future__ import annotations
import shutil
from collections.abc import Iterator
from pathlib import Path
import httpx
import pytest
from playwright.sync_api import Page, expect
# The hello_world agent spec uses ``os_env.cwd: .``, so the runner writes seeded
# files into the server process's cwd — the repo root (this file is
# ``<repo>/tests/e2e_ui/comments/...``, so the repo root is ``parents[3]``).
_REPO_ROOT = Path(__file__).resolve().parents[3]
_HTML_PATH = "design_doc.html"
# A distinctive sentence that appears exactly once and is identical in the
# rendered text and the raw source (no inline markup inside it), so the rendered
# selection maps to a single, deterministic offset in the source.
_ANCHOR_SENTENCE = "uniqueanchortoken design review sentence"
# The rendered text of a paragraph whose SOURCE wraps the words across multiple
# indented lines. The browser collapses that whitespace when rendering, so the
# rendered selection ("... one single line") is NOT a verbatim substring of the
# source — it exercises the whitespace-tolerant anchor matching on both sides.
_WRAPPED_RENDERED = (
"uniquewrapped this rendered sentence spans several source lines yet reads as one single line"
)
_WRAPPED_SOURCE = (
"uniquewrapped this rendered sentence\n"
" spans several source lines\n"
" yet reads as one single line"
)
# A phrase that appears twice — once as a title, once in body prose — so a
# comment on the title must highlight ONLY the title, not both copies.
_REPEATED_PHRASE = "uniquerepeated Aurora Sync"
# A sentence far down the document (after tall filler) so activating its comment
# must scroll the iframe to bring it into view.
_DEEP_SENTENCE = "uniquedeeptoken sentence near the bottom of the document"
_FILLER = "\n".join(
f" <p>Filler paragraph {i} providing vertical space.</p>" for i in range(60)
)
_HTML_CONTENT = f"""\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Design doc</title>
</head>
<body>
<h1>Design Doc</h1>
<h2 id="repeated-title">{_REPEATED_PHRASE}</h2>
<p id="anchor">{_ANCHOR_SENTENCE}</p>
<p id="wrapped">{_WRAPPED_SOURCE}</p>
<p>Body prose mentioning <span id="repeated-body">{_REPEATED_PHRASE}</span> again.</p>
{_FILLER}
<p id="deep">{_DEEP_SENTENCE}</p>
</body>
</html>
"""
def _cleanup_session_workdir(session_id: str) -> None:
shutil.rmtree(_REPO_ROOT / session_id, ignore_errors=True)
@pytest.fixture
def seeded_html(seeded_session: tuple[str, str]) -> Iterator[tuple[str, str, str]]:
"""Seed the HTML doc and yield ``(base_url, session_id, path)``."""
base_url, session_id = seeded_session
resp = httpx.put(
f"{base_url}/v1/sessions/{session_id}"
f"/resources/environments/default/filesystem/{_HTML_PATH}",
json={"content": _HTML_CONTENT, "encoding": "utf-8"},
timeout=10.0,
)
resp.raise_for_status()
try:
yield (base_url, session_id, _HTML_PATH)
finally:
_cleanup_session_workdir(session_id)
def test_html_preview_add_comment(
page: Page,
seeded_html: tuple[str, str, str],
) -> None:
"""Select rendered HTML text, add a comment, and verify it persists."""
base_url, session_id, file_path = seeded_html
# Wide viewport so the toolbar renders inline and the panel has room.
page.set_viewport_size({"width": 1600, "height": 900})
# HTML files default to preview, so the iframe mounts directly.
page.goto(f"{base_url}/c/{session_id}?file={_HTML_PATH}")
file_viewer = page.locator('[data-testid="file-viewer"]:visible')
expect(file_viewer).to_be_visible()
iframe_el = file_viewer.locator('iframe[title="HTML preview"]')
expect(iframe_el).to_be_visible(timeout=10_000)
# Reach into the sandboxed (opaque-origin) frame via CDP and select the
# anchor sentence. select_text drives a programmatic selection, which the
# bridge picks up via its debounced selectionchange listener.
preview = file_viewer.frame_locator('iframe[title="HTML preview"]')
expect(preview.locator("#anchor")).to_have_text(_ANCHOR_SENTENCE, timeout=10_000)
preview.locator("#anchor").select_text()
# The floating "Add comment" button is portalled to the PARENT document
# (not inside the frame), so find it on the page.
add_comment_btn = page.get_by_role("button", name="Add comment")
expect(add_comment_btn).to_be_visible(timeout=10_000)
add_comment_btn.click()
# CommentsPanel opens alongside the preview.
expect(file_viewer.locator("span.font-semibold", has_text="Comments")).to_be_visible()
comment_body = "This sentence needs a citation."
comment_textarea = file_viewer.locator("textarea[placeholder='Add a comment…']")
expect(comment_textarea).to_be_visible()
comment_textarea.fill(comment_body)
file_viewer.get_by_role("button", name="Add Comment").click()
# The comment card appears in the panel.
expect(file_viewer).to_contain_text(comment_body)
# Verify via the REST API that the comment persisted with the selected
# sentence as its anchor at an offset matching the raw HTML source.
comments_resp = httpx.get(
f"{base_url}/v1/sessions/{session_id}/comments?path={file_path}",
timeout=10.0,
)
comments_resp.raise_for_status()
comments = comments_resp.json()
assert len(comments) == 1, f"Expected 1 comment, got {len(comments)}: {comments}"
comment = comments[0]
assert comment["body"] == comment_body
assert comment["anchor_content"] == _ANCHOR_SENTENCE, (
f"anchor_content {comment['anchor_content']!r} != selected sentence {_ANCHOR_SENTENCE!r}"
)
raw_idx = _HTML_CONTENT.find(_ANCHOR_SENTENCE)
assert raw_idx != -1, "fixture bug: anchor sentence missing from file content"
assert comment["start_index"] == raw_idx, (
f"stored start_index={comment['start_index']} does not match the raw "
f"source position {raw_idx} of the anchor sentence"
)
assert comment["end_index"] == raw_idx + len(_ANCHOR_SENTENCE)
def _open_preview(page: Page, base_url: str, session_id: str):
"""Navigate to the HTML file and return the (file_viewer, preview) locators."""
page.set_viewport_size({"width": 1600, "height": 900})
page.goto(f"{base_url}/c/{session_id}?file={_HTML_PATH}")
file_viewer = page.locator('[data-testid="file-viewer"]:visible')
expect(file_viewer).to_be_visible()
expect(file_viewer.locator('iframe[title="HTML preview"]')).to_be_visible(timeout=10_000)
return file_viewer, file_viewer.frame_locator('iframe[title="HTML preview"]')
def _add_comment_on(file_viewer, page: Page, locator_id: str, body: str) -> None:
"""Select the element with ``locator_id`` in the frame and add ``body``."""
preview = file_viewer.frame_locator('iframe[title="HTML preview"]')
preview.locator(f"#{locator_id}").select_text()
add_btn = page.get_by_role("button", name="Add comment")
expect(add_btn).to_be_visible(timeout=10_000)
add_btn.click()
textarea = file_viewer.locator("textarea[placeholder='Add a comment…']")
expect(textarea).to_be_visible()
textarea.fill(body)
file_viewer.get_by_role("button", name="Add Comment").click()
expect(file_viewer).to_contain_text(body)
def test_wrapped_anchor_matches_across_source_lines(
page: Page,
seeded_html: tuple[str, str, str],
) -> None:
"""A selection whose source wraps across lines still anchors + highlights.
The rendered text collapses the source's newlines/indentation, so the
selection is not a verbatim source substring. This pins the whitespace-
tolerant matching: the stored comment must anchor to the wrapped source span,
and the bridge must paint a Custom Highlight over the rendered range (the
matcher inside the frame would otherwise find nothing and leave it unpainted).
"""
base_url, session_id, file_path = seeded_html
file_viewer, preview = _open_preview(page, base_url, session_id)
expect(preview.locator("#wrapped")).to_have_text(_WRAPPED_RENDERED, timeout=10_000)
_add_comment_on(file_viewer, page, "wrapped", "wrapped-anchor comment")
# The stored comment anchors to the wrapped span in the RAW source.
comments = _get_comments(base_url, session_id, file_path)
assert len(comments) == 1, comments
raw_idx = _HTML_CONTENT.find(_WRAPPED_SOURCE)
assert raw_idx != -1, "fixture bug: wrapped source span missing from file"
assert comments[0]["start_index"] == raw_idx
assert comments[0]["end_index"] == raw_idx + len(_WRAPPED_SOURCE)
# The bridge painted the highlight over the rendered range (Custom Highlight
# API is available in the Chromium harness).
assert _highlight_count(preview) >= 1, "expected the wrapped anchor to be highlighted"
def test_repeated_anchor_highlights_only_commented_occurrence(
page: Page,
seeded_html: tuple[str, str, str],
) -> None:
"""A comment on repeated anchor text highlights only its own occurrence.
``_REPEATED_PHRASE`` appears twice — as a title and again in body prose. The
bridge highlights by text match, so without the occurrence disambiguation it
would light up both copies. Commenting on the title must paint exactly one
highlight (the title's), matching the offset the parent stored.
"""
base_url, session_id, file_path = seeded_html
file_viewer, preview = _open_preview(page, base_url, session_id)
expect(preview.locator("#repeated-title")).to_have_text(_REPEATED_PHRASE, timeout=10_000)
_add_comment_on(file_viewer, page, "repeated-title", "title comment")
# The comment anchored to the FIRST occurrence (the title) in the source.
comments = _get_comments(base_url, session_id, file_path)
assert len(comments) == 1, comments
title_idx = _HTML_CONTENT.find(_REPEATED_PHRASE)
assert comments[0]["start_index"] == title_idx
# Exactly one range is highlighted, even though the phrase appears twice.
assert _HTML_CONTENT.count(_REPEATED_PHRASE) == 2, "fixture bug: phrase should appear twice"
assert _highlight_count(preview) == 1, "only the commented occurrence should be highlighted"
def test_repeated_anchor_second_occurrence_anchors_to_its_own_offset(
page: Page,
seeded_html: tuple[str, str, str],
) -> None:
"""Selecting the SECOND copy of repeated text anchors to the second offset.
The inbound path: the bridge reports which occurrence was selected, and the
parent resolves that occurrence's source offset. Without it, both copies
resolve to the first match — so commenting on the body copy would be stored
at the title's offset (and clicking it would open the title's comment).
"""
base_url, session_id, file_path = seeded_html
file_viewer, preview = _open_preview(page, base_url, session_id)
expect(preview.locator("#repeated-body")).to_have_text(_REPEATED_PHRASE, timeout=10_000)
_add_comment_on(file_viewer, page, "repeated-body", "body comment")
# The stored comment must anchor to the SECOND occurrence in the source, not
# the title's first one.
comments = _get_comments(base_url, session_id, file_path)
assert len(comments) == 1, comments
title_idx = _HTML_CONTENT.find(_REPEATED_PHRASE)
body_idx = _HTML_CONTENT.find(_REPEATED_PHRASE, title_idx + 1)
assert body_idx != -1, "fixture bug: phrase should appear twice"
assert comments[0]["start_index"] == body_idx, (
f"comment anchored to {comments[0]['start_index']} (title is {title_idx}); "
f"expected the body occurrence at {body_idx}"
)
def test_native_selection_cleared_after_comment_saved(
page: Page,
seeded_html: tuple[str, str, str],
) -> None:
"""After saving a comment, the native selection is dropped inside the frame.
The browser's ``::selection`` paints over the (lower-priority) Custom
Highlight, so a lingering selection would keep the just-commented range grey
instead of yellow until the user clicked elsewhere. The bridge clears it once
a saved comment covers the selection.
"""
base_url, session_id, _ = seeded_html
file_viewer, preview = _open_preview(page, base_url, session_id)
expect(preview.locator("#anchor")).to_have_text(_ANCHOR_SENTENCE, timeout=10_000)
_add_comment_on(file_viewer, page, "anchor", "clears selection")
# The selection inside the frame must be collapsed after save.
selection_text = preview.locator("body").evaluate("() => window.getSelection().toString()")
assert selection_text == "", f"native selection should be cleared, got {selection_text!r}"
# And the highlight must be present (it was masked by grey before the clear).
assert _highlight_count(preview) >= 1
def test_clicking_comment_scrolls_highlight_into_view(
page: Page,
seeded_html: tuple[str, str, str],
) -> None:
"""Activating a comment scrolls the sandboxed frame to its highlight."""
base_url, session_id, file_path = seeded_html
# Seed the deep comment via the REST API rather than the UI: the floating
# "Add comment" button tracks the selection rect, so commenting on a
# below-the-fold sentence would place the button off-screen. The scroll
# behavior under test is driven by clicking the card, not by how it was made.
raw_idx = _HTML_CONTENT.find(_DEEP_SENTENCE)
assert raw_idx != -1, "fixture bug: deep sentence missing from file content"
httpx.post(
f"{base_url}/v1/sessions/{session_id}/comments",
json={
"path": file_path,
"body": "deep comment",
"start_index": raw_idx,
"end_index": raw_idx + len(_DEEP_SENTENCE),
"anchor_content": _DEEP_SENTENCE,
},
timeout=10.0,
).raise_for_status()
file_viewer, preview = _open_preview(page, base_url, session_id)
# The frame starts at the top; the deep highlight is far below the fold.
assert preview.locator("body").evaluate("() => window.pageYOffset") == 0
# Open the comments panel via the toolbar (the seeded comment isn't shown
# until the panel is open).
file_viewer.get_by_role("button", name="Show comments").click()
# Clicking the comment card in the panel activates it → the bridge scrolls.
file_viewer.get_by_text("deep comment").click()
page.wait_for_timeout(800) # smooth scroll settle
scrolled = preview.locator("body").evaluate("() => window.pageYOffset")
assert scrolled > 0, "activating the comment should scroll the frame to the highlight"
def test_selecting_highlight_scrolls_comment_panel_to_its_card(
page: Page,
seeded_html: tuple[str, str, str],
) -> None:
"""Selecting a highlighted range in the file reveals its card in the panel.
The reverse direction of the scroll test: with many comments the panel list
scrolls, so clicking a highlight near the bottom of the doc must scroll the
panel to that comment's card (it would otherwise stay out of view).
"""
base_url, session_id, file_path = seeded_html
# Seed a comment on each filler paragraph so the panel list is long, plus one
# on the deep sentence — the target we'll select in the file.
def _post(anchor: str, body: str) -> None:
idx = _HTML_CONTENT.find(anchor)
assert idx != -1, f"fixture bug: {anchor!r} missing"
httpx.post(
f"{base_url}/v1/sessions/{session_id}/comments",
json={
"path": file_path,
"body": body,
"start_index": idx,
"end_index": idx + len(anchor),
"anchor_content": anchor,
},
timeout=10.0,
).raise_for_status()
for i in range(30):
_post(f"Filler paragraph {i} providing vertical space.", f"filler comment {i}")
_post(_DEEP_SENTENCE, "deep card")
file_viewer, preview = _open_preview(page, base_url, session_id)
file_viewer.get_by_role("button", name="Show comments").click()
# Select the deep sentence in the file (scroll it into the frame's view first
# so its rendered range is laid out, then select it).
preview.locator("#deep").scroll_into_view_if_needed()
preview.locator("#deep").select_text()
# The deep comment's card must scroll into the panel's visible viewport.
deep_card = file_viewer.get_by_text("deep card")
expect(deep_card).to_be_in_viewport(timeout=5_000)
def _get_comments(base_url: str, session_id: str, file_path: str) -> list[dict]:
resp = httpx.get(
f"{base_url}/v1/sessions/{session_id}/comments?path={file_path}",
timeout=10.0,
)
resp.raise_for_status()
return resp.json()
def _highlight_count(preview) -> int:
"""Number of ranges the bridge registered under the base comment highlight."""
return preview.locator("body").evaluate(
"() => { const h = (typeof CSS !== 'undefined' && CSS.highlights)"
" ? CSS.highlights.get('omni-comment') : null; return h ? h.size : 0; }"
)