feat: share resumable mobile search previews (Closes #787)
This commit is contained in:
parent
66a2b78860
commit
43163ed926
|
|
@ -541,6 +541,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
|
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
|
||||||
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||||
|
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
|
||||||
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||||
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||||
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
|
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
|
||||||
|
|
|
||||||
|
|
@ -4319,6 +4319,7 @@
|
||||||
const status = qs('#search-preview-status');
|
const status = qs('#search-preview-status');
|
||||||
const claimButton = qs('#claim-search-result');
|
const claimButton = qs('#claim-search-result');
|
||||||
const startButton = qs('#start-search-result');
|
const startButton = qs('#start-search-result');
|
||||||
|
const shareButton = qs('#share-search-result');
|
||||||
|
|
||||||
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
|
qs('#close-search-preview').textContent = searchPreviewReturnKind === 'today-readiness'
|
||||||
? 'Back to blockers' : 'Back to search';
|
? 'Back to blockers' : 'Back to search';
|
||||||
|
|
@ -4331,6 +4332,7 @@
|
||||||
claimButton.disabled = false;
|
claimButton.disabled = false;
|
||||||
startButton.hidden = true;
|
startButton.hidden = true;
|
||||||
startButton.disabled = false;
|
startButton.disabled = false;
|
||||||
|
shareButton.disabled = true;
|
||||||
|
|
||||||
if (state.status === 'loading') {
|
if (state.status === 'loading') {
|
||||||
searchPreviewDetail = null;
|
searchPreviewDetail = null;
|
||||||
|
|
@ -4349,6 +4351,7 @@
|
||||||
const detail = state.detail;
|
const detail = state.detail;
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
searchPreviewDetail = detail;
|
searchPreviewDetail = detail;
|
||||||
|
shareButton.disabled = state.status === 'sharing';
|
||||||
qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number;
|
qs('#search-preview-key').textContent = detail.repository + ' #' + detail.number;
|
||||||
qs('#search-preview-title').textContent = detail.title || 'Untitled work item';
|
qs('#search-preview-title').textContent = detail.title || 'Untitled work item';
|
||||||
qs('#search-preview-meta').textContent =
|
qs('#search-preview-meta').textContent =
|
||||||
|
|
@ -4366,12 +4369,18 @@
|
||||||
startButton.textContent = detail.reopenable ? 'Reopen & resume' :
|
startButton.textContent = detail.reopenable ? 'Reopen & resume' :
|
||||||
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
|
(detail.assigned_to_me ? 'Start in Today' : 'Assign & start');
|
||||||
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
|
startButton.disabled = state.status === 'claiming' || state.status === 'reopening';
|
||||||
status.textContent = state.status === 'reopening' ? 'Reopening…' :
|
const shareStatus = {
|
||||||
(state.status === 'claiming' ? 'Assigning this issue to you…' :
|
sharing:'Opening share options…', shared:'Search result shared.', copied:'Search result link copied.',
|
||||||
(state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' :
|
'share-canceled':'Share canceled.', 'share-error':'Could not share this result. Try again.',
|
||||||
(detail.claimable ? 'This issue is open and unassigned.' :
|
};
|
||||||
(detail.assigned_to_me ? 'This item is already in My Work.' :
|
if (shareStatus[state.status]) status.textContent = shareStatus[state.status];
|
||||||
(detail.reopenable ? 'Closed—reopen to resume.' : 'Read-only preview.')))));
|
else if (state.status === 'reopening') status.textContent = 'Reopening…';
|
||||||
|
else if (state.status === 'claiming') status.textContent = 'Assigning this issue to you…';
|
||||||
|
else if (state.status === 'claimed') status.textContent = 'Assignment confirmed. Opening My Work…';
|
||||||
|
else if (detail.claimable) status.textContent = 'This issue is open and unassigned.';
|
||||||
|
else if (detail.assigned_to_me) status.textContent = 'This item is already in My Work.';
|
||||||
|
else if (detail.reopenable) status.textContent = 'Closed—reopen to resume.';
|
||||||
|
else status.textContent = 'Read-only preview.';
|
||||||
}
|
}
|
||||||
const searchPreview = createSearchPreview({
|
const searchPreview = createSearchPreview({
|
||||||
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
|
fetchJson: item => fetchReviewJson(searchPreviewPath(item), { headers:{ Accept:'application/json' } }),
|
||||||
|
|
@ -4380,8 +4389,14 @@
|
||||||
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
|
'/issues/' + encodeURIComponent(detail.number) + '/' + action,
|
||||||
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
||||||
),
|
),
|
||||||
|
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
|
||||||
onState: renderSearchPreview,
|
onState: renderSearchPreview,
|
||||||
});
|
});
|
||||||
|
function canonicalSearchPreviewUrl() {
|
||||||
|
const { query, preview } = taskOverlayHistory.currentState();
|
||||||
|
const params = new URLSearchParams({ search:query || '', preview:preview.kind + ':' + preview.repository + ':' + preview.number });
|
||||||
|
return new URL('?' + params, window.location.origin + window.location.pathname).href;
|
||||||
|
}
|
||||||
function createSearchStart(claim) {
|
function createSearchStart(claim) {
|
||||||
return createAssignAndStart({
|
return createAssignAndStart({
|
||||||
available: createAndStart.available,
|
available: createAndStart.available,
|
||||||
|
|
@ -4613,6 +4628,9 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
qs('#close-search-preview').addEventListener('click', closeSearchPreview);
|
qs('#close-search-preview').addEventListener('click', closeSearchPreview);
|
||||||
|
qs('#share-search-result').addEventListener('click', () => {
|
||||||
|
searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {});
|
||||||
|
});
|
||||||
document.querySelectorAll('.share-work-route').forEach(button => {
|
document.querySelectorAll('.share-work-route').forEach(button => {
|
||||||
button.addEventListener('click', async () => {
|
button.addEventListener('click', async () => {
|
||||||
const status = qs('#work-route-share-status');
|
const status = qs('#work-route-share-status');
|
||||||
|
|
|
||||||
|
|
@ -451,6 +451,7 @@
|
||||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<button id="share-search-result" type="button" disabled>Share result</button>
|
||||||
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
|
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
|
||||||
function validShareContinuation(value) {
|
function validShareContinuation(value) {
|
||||||
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
|
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
|
||||||
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5 };
|
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5, search: 200, preview: 200 };
|
||||||
const params = new URLSearchParams(value.slice(3));
|
const params = new URLSearchParams(value.slice(3));
|
||||||
const entries = Array.from(params.entries());
|
const entries = Array.from(params.entries());
|
||||||
if (!entries.length) return './';
|
if (!entries.length) return './';
|
||||||
|
|
@ -15,6 +15,14 @@
|
||||||
));
|
));
|
||||||
if (invalid) return './';
|
if (invalid) return './';
|
||||||
if (Object.keys(limits).some(name => params.getAll(name).length > 1)) return './';
|
if (Object.keys(limits).some(name => params.getAll(name).length > 1)) return './';
|
||||||
|
const search = params.get('search');
|
||||||
|
const preview = params.get('preview');
|
||||||
|
if (search !== null || preview !== null) {
|
||||||
|
if (entries.some(([name]) => !['search', 'preview'].includes(name))) return './';
|
||||||
|
if (search === null || preview === null) return './';
|
||||||
|
if (!/^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9]\d*$/.test(preview)) return './';
|
||||||
|
return value;
|
||||||
|
}
|
||||||
const launch = params.get('launch');
|
const launch = params.get('launch');
|
||||||
const shared = params.get('shared');
|
const shared = params.get('shared');
|
||||||
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
|
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
|
||||||
|
|
@ -87,7 +95,12 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
|
if (continuation !== './') {
|
||||||
|
const continuationParams = new URLSearchParams(continuation.slice(3));
|
||||||
|
status.textContent = continuationParams.has('preview')
|
||||||
|
? 'Sign in to open the shared Search result.'
|
||||||
|
: 'Sign in to continue your shared capture.';
|
||||||
|
}
|
||||||
|
|
||||||
async function showReason(reason) {
|
async function showReason(reason) {
|
||||||
if (reason === 'session-expired') {
|
if (reason === 'session-expired') {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@
|
||||||
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
|
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
|
||||||
if (root) root.createSearchPreview = createSearchPreview;
|
if (root) root.createSearchPreview = createSearchPreview;
|
||||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
return function createSearchPreview({ fetchJson, mutate, onState }) {
|
return function createSearchPreview({ fetchJson, mutate, share, onState }) {
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
let current = null;
|
let current = null;
|
||||||
let mutationRequest = null;
|
let mutationRequest = null;
|
||||||
|
let shareRequest = null;
|
||||||
|
|
||||||
function run(action, pending, success, detail) {
|
function run(action, pending, success, detail) {
|
||||||
if (mutationRequest) return mutationRequest;
|
if (mutationRequest) return mutationRequest;
|
||||||
|
|
@ -29,6 +30,7 @@
|
||||||
onState({ status: 'loading', item: current });
|
onState({ status: 'loading', item: current });
|
||||||
return fetchJson(current).then(detail => {
|
return fetchJson(current).then(detail => {
|
||||||
if (requestGeneration === generation) {
|
if (requestGeneration === generation) {
|
||||||
|
current = { ...current, ...detail };
|
||||||
onState({ status: 'ready', item: current, detail });
|
onState({ status: 'ready', item: current, detail });
|
||||||
}
|
}
|
||||||
return detail;
|
return detail;
|
||||||
|
|
@ -44,6 +46,20 @@
|
||||||
current = null;
|
current = null;
|
||||||
onState({ status: 'closed' });
|
onState({ status: 'closed' });
|
||||||
},
|
},
|
||||||
|
share(url) {
|
||||||
|
if (shareRequest) return shareRequest;
|
||||||
|
if (!current || typeof share !== 'function') return Promise.reject(new Error('Sharing is unavailable.'));
|
||||||
|
const detail = current;
|
||||||
|
onState({ status:'sharing', item:current, detail });
|
||||||
|
shareRequest = share(url).then(result => {
|
||||||
|
onState({ status:result, item:current, detail });
|
||||||
|
return result;
|
||||||
|
}).catch(error => {
|
||||||
|
onState({ status:error?.name === 'AbortError' ? 'share-canceled' : 'share-error', item:current, detail, error });
|
||||||
|
throw error;
|
||||||
|
}).finally(() => { shareRequest = null; });
|
||||||
|
return shareRequest;
|
||||||
|
},
|
||||||
claim(detail) {
|
claim(detail) {
|
||||||
return run('claim', 'claiming', 'claimed', detail);
|
return run('claim', 'claiming', 'claimed', detail);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ FEATURE_SOURCES = {
|
||||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||||
"security-center": ("static/security-center.js",),
|
"security-center": ("static/security-center.js",),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/task-overlay-history.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
"static/task-overlay-history.js", "static/search-preview.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||||
|
|
|
||||||
21
src/main.py
21
src/main.py
|
|
@ -5,6 +5,7 @@ import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
|
|
@ -1002,6 +1003,26 @@ app.include_router(frontend_router)
|
||||||
|
|
||||||
|
|
||||||
def _share_target_login_redirect(request: Request) -> str:
|
def _share_target_login_redirect(request: Request) -> str:
|
||||||
|
search_values = request.query_params.getlist("search")
|
||||||
|
preview_values = request.query_params.getlist("preview")
|
||||||
|
if search_values or preview_values:
|
||||||
|
allowed = {"search", "preview"}
|
||||||
|
if (
|
||||||
|
set(request.query_params.keys()) - allowed
|
||||||
|
or len(search_values) != 1
|
||||||
|
or len(preview_values) != 1
|
||||||
|
or len(search_values[0]) > 200
|
||||||
|
or len(preview_values[0]) > 200
|
||||||
|
or not re.fullmatch(
|
||||||
|
r"(?:issue|pull):[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+:[1-9]\d*",
|
||||||
|
preview_values[0],
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return "login"
|
||||||
|
continuation = urlencode(
|
||||||
|
(("search", search_values[0].strip()), ("preview", preview_values[0]))
|
||||||
|
)
|
||||||
|
return f"login?{urlencode({'continue': f'./?{continuation}'})}"
|
||||||
limits = {"title": 200, "text": 8000, "url": 2048}
|
limits = {"title": 200, "text": 8000, "url": 2048}
|
||||||
if any(
|
if any(
|
||||||
len(request.query_params.get(name, "")) > limit
|
len(request.query_params.get(name, "")) > limit
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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():
|
def test_remote_search_selection_opens_native_preview_without_navigation():
|
||||||
html = dashboard_bundle_text()
|
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
|
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():
|
def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
|
||||||
html = dashboard_bundle_text()
|
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"
|
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
|
@pytest.mark.anyio
|
||||||
async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control):
|
async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control):
|
||||||
transport = httpx.ASGITransport(app=main.app)
|
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():
|
def test_passkey_login_uses_web_authentication_without_sending_the_operator_token():
|
||||||
harness = f"""
|
harness = f"""
|
||||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
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 'id="search-preview" role="dialog" aria-modal="true"' in html
|
||||||
assert 'aria-labelledby="search-preview-title"' in html
|
assert 'aria-labelledby="search-preview-title"' in html
|
||||||
assert 'id="claim-search-result"' 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 'id="open-search-result-gitea"' in html
|
||||||
assert "static/search-preview.js" in FRONTEND_BUILD.page_sources
|
assert "static/search-preview.js" in FRONTEND_BUILD.page_sources
|
||||||
assert ".search-preview-panel" in css
|
assert ".search-preview-panel" in css
|
||||||
assert "height:100dvh" in css
|
assert "height:100dvh" in css
|
||||||
assert "env(safe-area-inset-bottom)" in css
|
assert "env(safe-area-inset-bottom)" in css
|
||||||
|
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user