Filler paragraph {i} providing vertical space.
" for i in range(60) ) _HTML_CONTENT = f"""\{_ANCHOR_SENTENCE}
{_WRAPPED_SOURCE}
Body prose mentioning {_REPEATED_PHRASE} again.
{_FILLER}{_DEEP_SENTENCE}
""" 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; }" )