feat: scope mobile search by type and status (Closes #793)
This commit is contained in:
1 parent
3c65bc1b31
commit
bd054998b2
14 files changed
+315
-37
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user