feat: show conversations in mobile search preview (Closes #803)
This commit is contained in:
1 parent
2406d24cb8
commit
51fe9e5af2
7 files changed
+281
-16
No files matched your search
@@ -386,6 +386,26 @@ process.stdout.write(JSON.stringify({{numbers:loaded.items.map(item => item.numb
|
||||
assert json.loads(result.stdout) == {"numbers": [1, 2], "more": False}
|
||||
|
||||
|
||||
def test_mobile_search_preview_progressively_renders_a_retryable_paged_conversation():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
preview_source = SEARCH_PREVIEW.read_text()
|
||||
|
||||
assert 'id="search-preview-conversation"' in html
|
||||
assert 'id="search-preview-comments"' in html
|
||||
assert 'id="search-preview-conversation-status"' in html
|
||||
assert 'id="retry-search-preview-conversation"' in html
|
||||
assert 'id="load-older-search-preview-comments"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "fetchConversation:" in html
|
||||
assert "api.retryConversation()" in preview_source
|
||||
assert "api.loadOlderConversation()" in preview_source
|
||||
assert "renderMarkdown(comment.body || '')" in preview_source
|
||||
assert ".search-preview-conversation" in css
|
||||
assert ".search-preview-comment" in css
|
||||
assert "min-height:44px" in css
|
||||
|
||||
|
||||
def test_mobile_search_preview_renders_session_navigation_and_advances_after_planning():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
@@ -440,6 +460,85 @@ if (ready.length !== 1 || ready[0].detail.number !== 2) {{
|
||||
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
def test_search_preview_publishes_detail_before_progressive_conversation_and_ignores_stale_comments():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const pending = new Map();
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve({{...item, title:'Issue ' + item.number}}),
|
||||
fetchConversation: item => new Promise(resolve => pending.set(item.number, resolve)),
|
||||
mutate: () => Promise.resolve(),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{repository:'stackchain/api', number:1, kind:'issue'}});
|
||||
const firstReady = states.find(state => state.status === 'ready' && state.detail?.number === 1);
|
||||
if (!firstReady || firstReady.conversation?.status !== 'loading') throw new Error('detail waited for conversation');
|
||||
await preview.open({{repository:'stackchain/api', number:2, kind:'issue'}});
|
||||
pending.get(1)({{comments:[{{id:1,body:'stale'}}], page:1, older_page:null}});
|
||||
pending.get(2)({{comments:[{{id:2,body:'current'}}], page:1, older_page:null}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const final = states.at(-1);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
number:final.detail.number,
|
||||
status:final.conversation.status,
|
||||
comments:final.conversation.comments.map(comment => comment.body),
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"number": 2,
|
||||
"status": "ready",
|
||||
"comments": ["current"],
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_retries_failed_conversation_and_prepends_older_messages_without_duplicates():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let calls = 0;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve({{...item, title:'Issue'}}),
|
||||
fetchConversation: (_item, page) => {{
|
||||
calls += 1;
|
||||
if (calls === 1) return Promise.reject(new Error('offline'));
|
||||
if (page === 1) return Promise.resolve({{comments:[{{id:1,body:'old'}},{{id:3,body:'duplicate'}}], older_page:null}});
|
||||
return Promise.resolve({{comments:[{{id:3,body:'newer'}},{{id:4,body:'newest'}}], older_page:1}});
|
||||
}},
|
||||
mutate: () => Promise.resolve(),
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{repository:'stackchain/api', number:42, kind:'issue'}});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
if (states.at(-1).conversation.status !== 'error') throw new Error('failure unavailable');
|
||||
await preview.retryConversation();
|
||||
await preview.loadOlderConversation();
|
||||
const final = states.at(-1).conversation;
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
status:final.status,
|
||||
ids:final.comments.map(comment => comment.id),
|
||||
olderPage:final.olderPage,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": 3,
|
||||
"status": "ready",
|
||||
"ids": [1, 3, 4],
|
||||
"olderPage": None,
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_claim_is_single_flight_and_reports_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
|
||||
@@ -186,6 +186,43 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat
|
||||
assert response.json()["claimable"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("kind", ["issue", "pull"])
|
||||
async def test_global_search_preview_conversation_returns_a_bounded_authorized_page(
|
||||
monkeypatch, kind
|
||||
):
|
||||
requested = []
|
||||
|
||||
async def conversation(repository, number, page, limit):
|
||||
requested.append((repository, number, page, limit))
|
||||
return {
|
||||
"comments": [{
|
||||
"id": 7,
|
||||
"author": "alexander",
|
||||
"body": "Current **decision**",
|
||||
"created_at": "2026-08-14T12:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/42#issuecomment-7",
|
||||
}],
|
||||
"page": 3,
|
||||
"older_page": 2,
|
||||
"total": 41,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "issue_conversation_page", conversation, raising=False
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
f"/api/v1/repos/stackchain/api/issues/42/preview/conversation?kind={kind}&limit=20"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["comments"][0]["body"] == "Current **decision**"
|
||||
assert requested == [("stackchain/api", 42, None, 20)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
|
||||
requests = []
|
||||
|
||||
Reference in new issue
Block a user