feat: watch issues from mobile search (Closes #1290)
CI / lint (pull_request) Successful in 4m17s
CI / build-release (pull_request) Successful in 9s
CI / browser-journey (pull_request) Successful in 7m6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-23 01:55:53 +00:00
1 parent 7fbbc2f942
commit 9c187b5ae5
8 files changed
+297 -3

No files matched your search

@@ -32,8 +32,14 @@ def test_rendered_mobile_search_preview_navigation_preserves_reading_space_and_d
page.locator("#search-preview-conversation").evaluate("node => node.style.minHeight = '500px'")
page.locator("#search-preview-reply-workspace").evaluate("node => node.style.minHeight = '500px'")
page.locator("#search-preview-actions").evaluate("node => node.style.minHeight = '300px'")
page.locator("#watch-search-result").evaluate("node => { node.hidden = false; node.textContent = 'Watch issue'; }")
page.add_script_tag(path=FRONTEND / "mobile-search-preview-nav.js")
watch = page.locator("#watch-search-result")
expect(watch).to_be_visible()
expect(watch).to_have_text("Watch issue")
watch_bounds = watch.bounding_box()
assert watch_bounds and watch_bounds["height"] >= 44
navigation = page.locator(".mobile-search-preview-nav")
expect(navigation).to_be_visible()
buttons = navigation.locator("button")
+43
View File
@@ -806,6 +806,49 @@ if (!states.some(state => state.status === 'reopened')) throw new Error('reopen
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_search_preview_watch_is_single_flight_and_keeps_visible_context_on_failure():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{
let calls = 0; let rejectWatch; const states = [];
const detail = {{repository:'stackchain/api',number:42,kind:'issue',claimable:true,watching:false}};
const preview = createSearchPreview({{
fetchJson:async()=>detail, mutate:async()=>{{}},
watch:()=>{{calls+=1;return new Promise((_resolve,reject)=>rejectWatch=reject);}},
onState:state=>states.push(state),
}});
await preview.open(detail);
const first=preview.setWatching(true); const second=preview.setWatching(true);
if (calls !== 1 || first !== second) throw new Error('watch mutation was not single-flight');
rejectWatch(new Error('offline')); await Promise.allSettled([first,second]);
const final=states.at(-1);
process.stdout.write(JSON.stringify({{calls,status:final.status,number:final.detail.number,
watching:final.detail.watching,error:final.error.message}}));
}})().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": 1,
"status": "watch-error",
"number": 42,
"watching": False,
"error": "offline",
}
def test_mobile_search_preview_exposes_touch_safe_watch_action():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
dashboard = (FRONTEND / "dashboard.js").read_text()
assert '<button id="watch-search-result" type="button" hidden>Watch issue</button>' in html
assert "wireSearchPreviewWatch" in dashboard
assert "preview.setWatching" in SEARCH_PREVIEW.read_text()
assert ".search-preview-actions button" in css and "min-height:44px" in css
def test_search_preview_shares_canonical_url_without_closing_the_preview():
script = f"""
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
+90
View File
@@ -218,6 +218,96 @@ async def test_global_search_preview_returns_normalized_action_context(monkeypat
assert response.json()["claimable"] is True
@pytest.mark.anyio
async def test_search_preview_subscription_reads_server_truth_without_caching(monkeypatch):
calls = []
async def preview(repository, kind, number):
calls.append(("preview", repository, kind, number))
return {"repository": repository, "kind": kind, "number": number,
"state": "open", "claimable": True}
async def subscription(repository, number):
calls.append(("subscription", repository, number))
return {"watching": True}
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "issue_subscription", subscription, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {"watching": True}
assert calls == [
("preview", "stackchain/api", "issue", 42),
("subscription", "stackchain/api", 42),
]
@pytest.mark.anyio
@pytest.mark.parametrize(("method", "watching"), [("PUT", True), ("DELETE", False)])
async def test_search_preview_subscription_mutation_revalidates_target_and_confirms_truth(
monkeypatch, method, watching
):
calls = []
async def preview(repository, kind, number):
calls.append(("preview", repository, kind, number))
return {"repository": repository, "kind": kind, "number": number,
"state": "open", "claimable": True}
async def set_subscription(repository, number, desired):
calls.append(("set", repository, number, desired))
return {"watching": desired}
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.request(
method,
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {"watching": watching}
assert calls == [
("preview", "stackchain/api", "issue", 42),
("set", "stackchain/api", 42, watching),
]
@pytest.mark.anyio
async def test_issue_subscription_mutation_uses_current_login_and_requires_confirmation():
requests = []
async def handler(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/subscriptions/check"):
return httpx.Response(200, json={"subscribed": True, "ignored": False})
return httpx.Response(201, json={})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.set_issue_subscription("stackchain/api", 42, True)
finally:
await gitea_proxy.stop_client()
assert result == {"watching": True}
assert requests == [
("GET", "/api/v1/user"),
("PUT", "/api/v1/repos/stackchain/api/issues/42/subscriptions/timmy"),
("GET", "/api/v1/repos/stackchain/api/issues/42/subscriptions/check"),
]
@pytest.mark.anyio
@pytest.mark.parametrize("kind", ["issue", "pull"])
async def test_global_search_preview_conversation_returns_a_bounded_authorized_page(