184 lines
7.2 KiB
Python
184 lines
7.2 KiB
Python
"""E2E: file-viewer and comment state survive a full browser reload.
|
|
|
|
These tests prove the durability path that the AppShell unit tests can
|
|
only approximate: ``AppShell.test.tsx`` mocks ``FileViewer`` and asserts
|
|
the ``?file=`` / ``?comment=`` params are wired into component state, but
|
|
it never re-fetches from a real server or re-hydrates a real viewer. Here
|
|
we seed state through the REST API, drive the real SPA, then call
|
|
``page.reload()`` and assert the same state re-renders from scratch —
|
|
which can only pass if (a) the server persisted it and (b) the SPA
|
|
rebuilds its view from the URL on a cold load.
|
|
|
|
No LLM is involved: files are seeded via the filesystem PUT endpoint and
|
|
comments via ``POST /v1/sessions/{id}/comments``, so the tests are
|
|
deterministic.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import shutil
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
from playwright.sync_api import Page, expect
|
|
|
|
# The agent spec uses ``os_env.cwd: .`` (see ``_TEST_AGENT_YAML`` in
|
|
# conftest), so filesystem PUTs land in ``<repo-root>/<session_id>/``
|
|
# next to the spawned server. Clean that per-session dir up in teardown
|
|
# so the suite leaves no untracked files behind.
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def _cleanup_session_workdir(session_id: str) -> None:
|
|
"""Remove the per-session working dir created by filesystem PUTs."""
|
|
shutil.rmtree(_REPO_ROOT / session_id, ignore_errors=True)
|
|
|
|
|
|
# A plain-text file whose unique body proves the viewer fetched and
|
|
# rendered real content (not an empty shell) before and after reload.
|
|
_TEXT_FILE_PATH = "reload_durability.txt"
|
|
_TEXT_FILE_CONTENT = "Durable file body that must survive a browser reload."
|
|
|
|
# A markdown file plus a single anchored comment. The comment body is a
|
|
# unique sentinel so its presence in the panel is unambiguous.
|
|
_MD_FILE_PATH = "reload_comment.md"
|
|
_MD_ANCHOR = "Anchor paragraph for the durable comment."
|
|
_MD_FILE_CONTENT = f"""\
|
|
# Reload Comment Durability
|
|
|
|
{_MD_ANCHOR}
|
|
"""
|
|
_COMMENT_BODY = "This comment must persist across a full browser reload."
|
|
|
|
|
|
def _seed_file(base_url: str, session_id: str, path: str, content: str) -> None:
|
|
"""PUT a file into the session workspace via the filesystem API."""
|
|
resp = httpx.put(
|
|
f"{base_url}/v1/sessions/{session_id}/resources/environments/default/filesystem/{path}",
|
|
json={"content": content, "encoding": "utf-8"},
|
|
timeout=10.0,
|
|
)
|
|
resp.raise_for_status()
|
|
|
|
|
|
@pytest.fixture
|
|
def seeded_text_file(seeded_session: tuple[str, str]) -> Iterator[tuple[str, str]]:
|
|
"""Seed ``_TEXT_FILE_PATH`` and yield ``(base_url, session_id)``."""
|
|
base_url, session_id = seeded_session
|
|
_seed_file(base_url, session_id, _TEXT_FILE_PATH, _TEXT_FILE_CONTENT)
|
|
try:
|
|
yield (base_url, session_id)
|
|
finally:
|
|
_cleanup_session_workdir(session_id)
|
|
|
|
|
|
@pytest.fixture
|
|
def seeded_comment(seeded_session: tuple[str, str]) -> Iterator[tuple[str, str, str]]:
|
|
"""Seed a markdown file + one anchored comment via REST.
|
|
|
|
Yields ``(base_url, session_id, comment_id)``. Offsets are computed
|
|
from the raw file body so the comment classifies as ``open`` (a
|
|
draft) and is therefore reachable via the ``?comment=`` deep link.
|
|
"""
|
|
base_url, session_id = seeded_session
|
|
_seed_file(base_url, session_id, _MD_FILE_PATH, _MD_FILE_CONTENT)
|
|
|
|
start = _MD_FILE_CONTENT.index(_MD_ANCHOR)
|
|
resp = httpx.post(
|
|
f"{base_url}/v1/sessions/{session_id}/comments",
|
|
json={
|
|
"path": _MD_FILE_PATH,
|
|
"body": _COMMENT_BODY,
|
|
"start_index": start,
|
|
"end_index": start + len(_MD_ANCHOR),
|
|
"anchor_content": _MD_ANCHOR,
|
|
},
|
|
timeout=10.0,
|
|
)
|
|
resp.raise_for_status()
|
|
comment_id = resp.json()["id"]
|
|
try:
|
|
yield (base_url, session_id, comment_id)
|
|
finally:
|
|
_cleanup_session_workdir(session_id)
|
|
|
|
|
|
def test_file_viewer_rehydrates_from_url_after_reload(
|
|
page: Page,
|
|
seeded_text_file: tuple[str, str],
|
|
) -> None:
|
|
"""Open a file via ``?file=``, reload, and assert it re-renders.
|
|
|
|
A failure means either the SPA didn't restore ``selectedFilePath``
|
|
from the URL on a cold load (AppShell hydration regression) or the
|
|
server didn't persist the seeded file (filesystem store regression).
|
|
The ``:visible`` filter targets whichever FileViewer instance is
|
|
on-screen — the desktop inline aside renders one and the mobile
|
|
push-panel renders another, both with the same testid.
|
|
"""
|
|
base_url, session_id = seeded_text_file
|
|
page.goto(f"{base_url}/c/{session_id}?file={_TEXT_FILE_PATH}")
|
|
|
|
file_viewer = page.locator('[data-testid="file-viewer"]:visible')
|
|
expect(file_viewer).to_be_visible()
|
|
# The open file is identified by its tab (the desktop viewer header no
|
|
# longer repeats a top-level filename — it's redundant with the tab).
|
|
# exact=True targets the close button, not the tab div whose accessible
|
|
# name also contains "Close <name>".
|
|
expect(
|
|
page.get_by_role("button", name=f"Close {_TEXT_FILE_PATH}", exact=True).first
|
|
).to_be_visible()
|
|
# Content proves the viewer fetched the real body, not just an
|
|
# empty shell keyed off the path.
|
|
expect(file_viewer.get_by_text(_TEXT_FILE_CONTENT).first).to_be_visible(timeout=20_000)
|
|
|
|
page.reload()
|
|
|
|
# After a cold reload the viewer must rebuild purely from the URL.
|
|
expect(page).to_have_url(re.compile(rf"file={re.escape(_TEXT_FILE_PATH)}"))
|
|
file_viewer_after = page.locator('[data-testid="file-viewer"]:visible')
|
|
expect(file_viewer_after).to_be_visible()
|
|
expect(
|
|
page.get_by_role("button", name=f"Close {_TEXT_FILE_PATH}", exact=True).first
|
|
).to_be_visible()
|
|
expect(file_viewer_after.get_by_text(_TEXT_FILE_CONTENT).first).to_be_visible(timeout=20_000)
|
|
|
|
|
|
def test_comment_persists_across_reload(
|
|
page: Page,
|
|
seeded_comment: tuple[str, str, str],
|
|
) -> None:
|
|
"""Deep-link to a comment, reload, and assert it re-renders.
|
|
|
|
The ``?comment=<id>`` param makes the FileViewer auto-open the
|
|
comments panel and surface the card. A failure after reload means
|
|
either the comment wasn't persisted server-side or the deep-link
|
|
rehydration broke — both are real durability regressions the
|
|
component test can't catch (it mocks the viewer).
|
|
"""
|
|
base_url, session_id, comment_id = seeded_comment
|
|
url = f"{base_url}/c/{session_id}?file={_MD_FILE_PATH}&comment={comment_id}"
|
|
page.goto(url)
|
|
|
|
file_viewer = page.locator('[data-testid="file-viewer"]:visible')
|
|
expect(file_viewer).to_contain_text(_COMMENT_BODY, timeout=20_000)
|
|
|
|
page.reload()
|
|
|
|
file_viewer_after = page.locator('[data-testid="file-viewer"]:visible')
|
|
expect(file_viewer_after).to_contain_text(_COMMENT_BODY, timeout=20_000)
|
|
|
|
# The comment is still the single persisted record — reload must not
|
|
# have duplicated or dropped it. A count != 1 would mean the reload
|
|
# re-posted the comment (the deep-link rehydration wrote instead of
|
|
# read) or the server lost it — both durability regressions.
|
|
comments = httpx.get(
|
|
f"{base_url}/v1/sessions/{session_id}/comments?path={_MD_FILE_PATH}",
|
|
timeout=10.0,
|
|
).json()
|
|
assert len(comments) == 1, f"Expected exactly 1 persisted comment, got {comments}"
|
|
assert comments[0]["body"] == _COMMENT_BODY
|