feat: scope mobile search by type and status (Closes #793)
CI / lint (pull_request) Successful in 1m45s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-14 02:34:44 +00:00
1 parent 3c65bc1b31
commit bd054998b2
14 files changed
+315 -37

No files matched your search

+51 -1
View File
@@ -52,6 +52,25 @@ def test_command_script_resolves_inside_dashboard_subpath():
) == "https://forge.alexanderwhitestone.com/dashboard/static/commands.js"
def test_mobile_search_renders_touch_sized_type_and_status_scope_controls():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert '<fieldset class="cmd-search-scope"' in html
assert '<legend>Filter search results</legend>' in html
assert '<label for="cmd-search-kind">Type</label>' in html
assert '<select id="cmd-search-kind"' in html
assert '<option value="pull">Pull requests</option>' in html
assert '<label for="cmd-search-state">Status</label>' in html
assert '<select id="cmd-search-state"' in html
assert '.cmd-search-scope' in css
assert '#cmd-search-kind, #cmd-search-state { min-height:44px;' in css
assert "commandSearch.setScope(scope)" in html
assert "taskOverlayHistory.update({ scope })" in html
assert "'&kind=' + encodeURIComponent(scope.kind)" in html
assert "'&state=' + encodeURIComponent(scope.state)" in html
def test_remote_command_search_ignores_stale_responses():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
@@ -85,6 +104,37 @@ if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result'
)
def test_remote_command_search_scope_change_aborts_and_restarts_without_stale_results():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
(async () => {{
const pending = [];
const states = [];
const controller = filterCommands.createGlobalSearchController({{
delay: 0,
search: (query, signal, page, scope) => new Promise(resolve => pending.push({{query, signal, page, scope, resolve}})),
onState: state => states.push(state),
}});
controller.setQuery('mobile');
await new Promise(resolve => setTimeout(resolve, 0));
controller.setScope({{kind:'pull', state:'open'}});
await new Promise(resolve => setTimeout(resolve, 0));
if (!pending[0].signal.aborted) throw new Error('scope change did not abort old request');
if (JSON.stringify(pending[1].scope) !== JSON.stringify({{kind:'pull', state:'open'}})) {{
throw new Error('new request did not receive scope');
}}
pending[1].resolve({{items:[{{kind:'pull',repository:'a/b',number:2}}],has_more:false,next_page:2}});
pending[0].resolve({{items:[{{kind:'issue',repository:'a/b',number:1}}],has_more:false,next_page:2}});
await new Promise(resolve => setTimeout(resolve, 0));
const ready = states.filter(state => state.status === 'ready').at(-1);
if (ready.items.length !== 1 || ready.items[0].kind !== 'pull') throw new Error('stale scope result entered list');
if (ready.scope.kind !== 'pull' || ready.scope.state !== 'open') throw new Error('scope missing from state');
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
def test_remote_command_search_aborts_superseded_and_cleared_queries():
script = f"""
const filterCommands = require({json.dumps(str(COMMANDS))});
@@ -193,7 +243,7 @@ def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath()
assert 'aria-controls="cmd-results"' in html
assert 'role="listbox"' in html
assert "fetch('api/v1/search?q='" in html
assert "async function searchGlobalWork(query, signal, page = 1)" in html
assert "async function searchGlobalWork(query, signal, page = 1, scope" in html
assert "signal," in html
assert "Some results are temporarily unavailable." in html
assert 'id="cmd-load-more"' in html
+24
View File
@@ -918,6 +918,30 @@ async def test_anonymous_search_preview_preserves_only_bounded_canonical_continu
assert oversized.headers["location"] == "login"
@pytest.mark.anyio
async def test_anonymous_search_preview_preserves_only_valid_search_scope(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
valid = await client.get("/", params={
"search": "release blocker", "preview": "pull:stackchain/api:42",
"search_kind": "pull", "search_state": "open",
})
invalid = await client.get("/", params={
"search": "release blocker", "preview": "pull:stackchain/api:42",
"search_kind": "script", "search_state": "secret",
})
valid_continue = parse_qs(urlsplit(valid.headers["location"]).query)["continue"][0]
invalid_continue = parse_qs(urlsplit(invalid.headers["location"]).query)["continue"][0]
assert valid_continue.endswith(
"search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42&"
"search_kind=pull&search_state=open"
)
assert invalid_continue == (
"./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A42"
)
@pytest.mark.anyio
async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control):
transport = httpx.ASGITransport(app=main.app)
+2
View File
@@ -66,6 +66,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
security_center = first.feature_bundles["security-center"]
assert b"function attachSecurityCenter" not in first.runtime_bytes
assert b"function attachSecurityCenter" in security_center.runtime_bytes
assert b"function createGlobalSearchController" not in first.runtime_bytes
assert b"function createGlobalSearchController" in first.feature_bundles["today-timer"].runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes
# Core mobile workflows stay below 98 KiB gzip, including transaction-safe Update decisions.
+58 -5
View File
@@ -6,12 +6,60 @@ import pytest
from src import gitea_proxy, main
@pytest.mark.anyio
async def test_global_search_endpoint_forwards_valid_type_and_status_scope(monkeypatch):
requested = []
async def search(query, limit, page, kind, state):
requested.append((query, limit, page, kind, state))
return {"items": [], "partial": False, "has_more": False, "next_page": 2}
monkeypatch.setattr(main.gitea_proxy, "global_search", search, 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/search?q=mobile&limit=7&page=1&kind=pull&state=open"
)
assert response.status_code == 200
assert requested == [("mobile", 7, 1, "pull", "open")]
assert response.json()["scope"] == {"kind": "pull", "state": "open"}
@pytest.mark.anyio
async def test_global_search_specific_type_uses_one_full_width_scoped_stream():
requests = []
async def handler(request):
requests.append(dict(request.url.params))
return httpx.Response(200, json=[{
"number": 9,
"title": "Review mobile search",
"state": "open",
"repository": {"full_name": "stackchain/web"},
"html_url": "https://forge.example/stackchain/web/pulls/9",
}])
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.global_search(
"mobile", limit=7, page=2, kind="pull", state="open"
)
finally:
await gitea_proxy.stop_client()
assert requests == [{
"q": "mobile", "type": "pulls", "state": "open", "limit": "7", "page": "2"
}]
assert [item["kind"] for item in result["items"]] == ["pull"]
@pytest.mark.anyio
async def test_global_search_endpoint_returns_bounded_normalized_results(monkeypatch):
requested = []
async def search(query, limit, page):
requested.append((query, limit, page))
async def search(query, limit, page, kind, state):
requested.append((query, limit, page, kind, state))
return {
"items": [{
"kind": "issue",
@@ -31,8 +79,8 @@ async def test_global_search_endpoint_returns_bounded_normalized_results(monkeyp
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert requested == [("mobile", 7, 2)]
assert response.json() == {"query": "mobile", "items": [{
assert requested == [("mobile", 7, 2, "all", "all")]
assert response.json() == {"query": "mobile", "scope": {"kind": "all", "state": "all"}, "items": [{
"kind": "issue",
"repository": "stackchain/api",
"number": 42,
@@ -110,7 +158,12 @@ async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results()
await gitea_proxy.stop_client()
assert {request["type"] for request in requests} == {"issues", "pulls"}
assert all(request["q"] == "mobile queue" and request["limit"] == "4" for request in requests)
assert all(
request["q"] == "mobile queue"
and request["limit"] == "4"
and request["state"] == "all"
for request in requests
)
assert results == {
"items": [{
"kind": "issue", "repository": "stackchain/api", "number": 42,
+37 -2
View File
@@ -207,7 +207,7 @@ def test_dashboard_routes_mobile_task_overlays_through_browser_history():
assert "createTaskOverlayHistory({" in html
assert "taskOverlayHistory.open('new')" in html
assert "taskOverlayHistory.open('find')" in html
assert "taskOverlayHistory.open('search')" in html
assert "taskOverlayHistory.open('search', { scope:currentSearchScope() })" in html
assert "taskOverlayHistory.open('search-preview')" in html
assert "taskOverlayHistory.close()" in html
assert "saveIssueCaptureDraft();" in html
@@ -290,10 +290,45 @@ process.stdout.write(JSON.stringify({{
assert payload["oversized"]["state"] == {"kind": None}
def test_search_history_round_trips_only_bounded_type_and_status_scope():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
function restore(search) {{
const location = {{pathname:'/dashboard/', search, hash:''}};
const history = {{state:null, replaceState(state, title, url) {{this.state=state; this.url=url;}}, back() {{}}}};
const controller = createTaskOverlayHistory({{
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
}});
return {{state:controller.currentState(), url:history.url}};
}}
const location = {{pathname:'/dashboard/', search:'', hash:''}};
const history = {{state:null, pushState(state,title,url) {{this.state=state; this.url=url;}}, replaceState() {{}}, back() {{}}}};
const controller = createTaskOverlayHistory({{
history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}},
}});
controller.open('search', {{query:'mobile', scope:{{kind:'pull',state:'open'}}}});
process.stdout.write(JSON.stringify({{
opened:controller.currentState(), url:history.url,
valid:restore('?search=mobile&search_kind=issue&search_state=closed'),
invalid:restore('?search=mobile&search_kind=script&search_state=secret'),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["opened"] == {
"kind": "search", "query": "mobile", "scope": {"kind": "pull", "state": "open"}
}
assert payload["url"] == "/dashboard/?search=mobile&search_kind=pull&search_state=open"
assert payload["valid"]["state"]["scope"] == {"kind": "issue", "state": "closed"}
assert payload["invalid"]["state"]["scope"] == {"kind": "all", "state": "all"}
def test_dashboard_persists_search_query_and_restores_canonical_preview():
html = dashboard_bundle_text()
assert "taskOverlayHistory.update({ query:e.target.value })" in html
assert "taskOverlayHistory.open('search-preview', { query:qs('#cmd-input').value, preview:item.result })" in html
assert "scope:currentSearchScope(), preview:item.result" in html
assert "detail?.query" in html
assert "searchPreview.open(detail.preview).catch" in html