feat: share resumable mobile search previews (Closes #787)
This commit is contained in:
1 parent
66a2b78860
commit
43163ed926
11 files changed
+234
-10
No files matched your search
@@ -282,6 +282,71 @@ 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_shares_canonical_url_without_closing_the_preview():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
const calls = [];
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item),
|
||||
mutate: () => Promise.resolve(),
|
||||
share: async url => {{ calls.push(url); return 'shared'; }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
const detail = {{ repository:'stackchain/api', number:42, kind:'issue' }};
|
||||
await preview.open(detail);
|
||||
const result = await preview.share('https://forge.example/dashboard/?search=release&preview=issue%3Astackchain%2Fapi%3A42');
|
||||
process.stdout.write(JSON.stringify({{ result, calls, states }}));
|
||||
}})().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
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["result"] == "shared"
|
||||
assert payload["calls"] == [
|
||||
"https://forge.example/dashboard/?search=release&preview=issue%3Astackchain%2Fapi%3A42"
|
||||
]
|
||||
assert payload["states"][-1]["status"] == "shared"
|
||||
assert payload["states"][-1]["detail"]["number"] == 42
|
||||
|
||||
|
||||
def test_search_preview_share_is_single_flight_and_cancellation_restores_ready_context():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
let calls = 0;
|
||||
let rejectShare;
|
||||
const states = [];
|
||||
const preview = createSearchPreview({{
|
||||
fetchJson: item => Promise.resolve(item), mutate: () => Promise.resolve(),
|
||||
share: () => {{ calls += 1; return new Promise((_resolve, reject) => rejectShare = reject); }},
|
||||
onState: state => states.push(state),
|
||||
}});
|
||||
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
|
||||
const first = preview.share('https://forge.example/dashboard/?search=x');
|
||||
const second = preview.share('https://forge.example/dashboard/?search=x');
|
||||
const canceled = new Error('canceled'); canceled.name = 'AbortError'; rejectShare(canceled);
|
||||
await Promise.allSettled([first, second]);
|
||||
const state = states.at(-1);
|
||||
process.stdout.write(JSON.stringify({{ calls, same:first === second, status:state.status,
|
||||
item:state.item, detail:state.detail, errorName:state.error?.name }}));
|
||||
}})().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,
|
||||
"same": True,
|
||||
"status": "share-canceled",
|
||||
"item": {"repository": "stackchain/api", "number": 42, "kind": "issue"},
|
||||
"detail": {"repository": "stackchain/api", "number": 42, "kind": "issue"},
|
||||
"errorName": "AbortError",
|
||||
}
|
||||
|
||||
|
||||
def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
@@ -294,6 +359,19 @@ def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||
assert "qs('#cmd-input').value = ''" not in remote_branch
|
||||
|
||||
|
||||
def test_search_preview_share_action_uses_canonical_stackchain_url_and_keeps_context():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
assert "share: url => createWorkRoute.share(url, navigator, navigator.clipboard)" in html
|
||||
handler = html.split("qs('#share-search-result').addEventListener('click'", 1)[1].split(
|
||||
"qs('#claim-search-result')", 1
|
||||
)[0]
|
||||
assert "canonicalSearchPreviewUrl()" in handler
|
||||
assert "searchPreview.share" in handler
|
||||
assert "closeSearchPreview" not in handler
|
||||
assert "taskOverlayHistory.close" not in handler
|
||||
|
||||
|
||||
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
|
||||
@@ -891,6 +891,33 @@ async def test_anonymous_app_shortcut_preserves_only_a_known_launch_action(acces
|
||||
assert invalid.headers["location"] == "login"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_search_preview_preserves_only_bounded_canonical_continuation(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": "issue:stackchain/api:42"}
|
||||
)
|
||||
malformed = await client.get(
|
||||
"/", params={"search": "release blocker", "preview": "issue:stackchain/api:0"}
|
||||
)
|
||||
duplicate = await client.get(
|
||||
"/?search=release&search=other&preview=issue%3Astackchain%2Fapi%3A42"
|
||||
)
|
||||
oversized = await client.get(
|
||||
"/", params={"search": "release", "preview": f"issue:{'x' * 201}/api:42"}
|
||||
)
|
||||
|
||||
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
|
||||
"continue": [
|
||||
"./?search=release+blocker&preview=issue%3Astackchain%2Fapi%3A42"
|
||||
]
|
||||
}
|
||||
assert malformed.headers["location"] == "login"
|
||||
assert duplicate.headers["location"] == "login"
|
||||
assert oversized.headers["location"] == "login"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
@@ -243,6 +243,53 @@ const controller = createLoginController({{
|
||||
)
|
||||
|
||||
|
||||
def test_successful_login_resumes_only_valid_search_preview_continuation():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
async function destination(continuation) {{
|
||||
let replaced = null;
|
||||
const controller = createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: value => replaced = value }}, continuation,
|
||||
}});
|
||||
await controller.submit('operator-token');
|
||||
return replaced;
|
||||
}}
|
||||
(async () => process.stdout.write(JSON.stringify({{
|
||||
valid:await destination('./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9'),
|
||||
malformed:await destination('./?search=release&preview=issue%3Astackchain%2Fapi%3A0'),
|
||||
unknown:await destination('./?search=release&next=https%3A%2F%2Fevil.example'),
|
||||
}})))().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"valid": "./?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9",
|
||||
"malformed": "./",
|
||||
"unknown": "./",
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_login_explains_the_resumable_handoff():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const status = {{ textContent: '' }};
|
||||
createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: () => {{}} }},
|
||||
continuation: './?search=release&preview=issue%3Astackchain%2Fapi%3A42',
|
||||
}});
|
||||
process.stdout.write(status.textContent);
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout == "Sign in to open the shared Search result."
|
||||
|
||||
|
||||
def test_passkey_login_uses_web_authentication_without_sending_the_operator_token():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
|
||||
@@ -41,8 +41,10 @@ async def test_global_search_preview_is_a_phone_safe_accessible_dialog():
|
||||
assert 'id="search-preview" role="dialog" aria-modal="true"' in html
|
||||
assert 'aria-labelledby="search-preview-title"' in html
|
||||
assert 'id="claim-search-result"' in html
|
||||
assert 'id="share-search-result"' in html
|
||||
assert 'id="open-search-result-gitea"' in html
|
||||
assert "static/search-preview.js" in FRONTEND_BUILD.page_sources
|
||||
assert ".search-preview-panel" in css
|
||||
assert "height:100dvh" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
|
||||
Reference in new issue
Block a user