1543 lines
65 KiB
Python
1543 lines
65 KiB
Python
import json
|
|
import subprocess
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
from urllib.parse import urljoin
|
|
|
|
from tests.dashboard_bundle import dashboard_bundle_text
|
|
|
|
|
|
FRONTEND = Path(__file__).parents[1] / "frontend"
|
|
COMMANDS = FRONTEND / "commands.js"
|
|
SEARCH_PREVIEW = FRONTEND / "search-preview.js"
|
|
MOBILE_SEARCH_VIEWPORT = FRONTEND / "mobile-search-viewport.js"
|
|
SEARCH_DEFER = FRONTEND / "search-defer.js"
|
|
|
|
|
|
class ScriptSourceParser(HTMLParser):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.sources = []
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
if tag == "script":
|
|
source = dict(attrs).get("src")
|
|
if source:
|
|
self.sources.append(source)
|
|
|
|
|
|
def test_filtered_command_runs_the_matching_action():
|
|
script = f"""
|
|
const filterCommands = require({json.dumps(str(COMMANDS))});
|
|
let action = '';
|
|
const commands = [
|
|
{{ name: 'Open whiteboard', run: () => {{ action = 'whiteboard'; }} }},
|
|
{{ name: 'Refresh now', run: () => {{ action = 'refresh'; }} }},
|
|
];
|
|
const matches = filterCommands(commands, 'refresh');
|
|
if (matches.length !== 1) throw new Error(`expected one match, got ${{matches.length}}`);
|
|
matches[0].run();
|
|
if (action !== 'refresh') throw new Error(`expected refresh, got ${{action}}`);
|
|
"""
|
|
|
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
|
|
def test_command_script_resolves_inside_dashboard_subpath():
|
|
parser = ScriptSourceParser()
|
|
parser.feed(dashboard_bundle_text())
|
|
command_source = next(source for source in parser.sources if source.endswith("commands.js"))
|
|
|
|
assert urljoin(
|
|
"https://forge.alexanderwhitestone.com/dashboard/", command_source
|
|
) == "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()
|
|
commands = COMMANDS.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 commands
|
|
assert "'&state=' + encodeURIComponent(scope.state)" in commands
|
|
|
|
|
|
def test_mobile_search_exposes_accessible_repository_scope_and_forwards_it():
|
|
html = dashboard_bundle_text()
|
|
css = (FRONTEND / "dashboard.css").read_text()
|
|
commands = COMMANDS.read_text()
|
|
|
|
assert '<label for="cmd-search-repository">Repository</label>' in html
|
|
assert '<input id="cmd-search-repository"' in html
|
|
assert 'list="cmd-search-repositories"' in html
|
|
assert '<datalist id="cmd-search-repositories">' in html
|
|
assert "api/v1/repositories/search?q=" in html
|
|
assert "repository:qs('#cmd-search-repository').value" in html
|
|
assert "'&repository=' + encodeURIComponent(scope.repository)" in commands
|
|
assert '#cmd-search-repository' in css
|
|
|
|
|
|
def test_mobile_search_preview_exposes_a_keyboard_safe_reply_and_continue_composer():
|
|
html = dashboard_bundle_text()
|
|
css = (FRONTEND / "dashboard.css").read_text()
|
|
preview = SEARCH_PREVIEW.read_text()
|
|
|
|
assert '<section class="search-preview-reply"' in html
|
|
assert '<textarea id="search-preview-reply"' in html
|
|
assert '<button id="send-search-preview-reply"' in html
|
|
assert '<button id="send-search-preview-reply-next"' in html
|
|
assert "searchPreviewReplyOptions(fetchReviewJson" in html
|
|
assert "root.searchPreviewReplyPath(item)" in preview
|
|
assert "api.saveReplyDraft" in preview
|
|
assert "api.reply({ advance:true })" in preview
|
|
assert ".search-preview-reply textarea" in css
|
|
assert "scroll-margin-bottom:calc(190px + env(safe-area-inset-bottom))" in css
|
|
|
|
|
|
def test_remote_command_search_repository_change_aborts_and_restarts_scoped_search():
|
|
script = f"""
|
|
const filterCommands = require({json.dumps(str(COMMANDS))});
|
|
(async () => {{
|
|
const pending = [];
|
|
const controller = filterCommands.createGlobalSearchController({{
|
|
delay: 0,
|
|
search: (query, signal, page, scope) => new Promise(resolve => pending.push({{signal, scope, resolve}})),
|
|
onState() {{}},
|
|
}});
|
|
controller.setQuery('mobile');
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
controller.setScope({{kind:'all', state:'all', repository:'stackchain/api'}});
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
if (!pending[0].signal.aborted) throw new Error('repository change did not abort old request');
|
|
if (pending[1].scope.repository !== 'stackchain/api') throw new Error('repository was not forwarded');
|
|
pending[1].resolve({{items:[],has_more:false,next_page:2}});
|
|
}})().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_ignores_stale_responses():
|
|
script = f"""
|
|
const filterCommands = require({json.dumps(str(COMMANDS))});
|
|
(async () => {{
|
|
const pending = new Map();
|
|
const states = [];
|
|
const controller = filterCommands.createGlobalSearchController({{
|
|
delay: 0,
|
|
search: query => new Promise(resolve => pending.set(query, resolve)),
|
|
onState: state => states.push(state),
|
|
}});
|
|
controller.setQuery('mobile');
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
controller.setQuery('release');
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
pending.get('release')([{{ title: 'Current result' }}]);
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
pending.get('mobile')([{{ title: 'Stale result' }}]);
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const ready = states.filter(state => state.status === 'ready');
|
|
if (ready.length !== 1) throw new Error(`expected one ready state, got ${{ready.length}}`);
|
|
if (ready[0].query !== 'release' || ready[0].items[0].title !== 'Current result') {{
|
|
throw new Error(`stale response replaced current state: ${{JSON.stringify(ready)}}`);
|
|
}}
|
|
}})().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_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))});
|
|
(async () => {{
|
|
const signals = [];
|
|
const states = [];
|
|
const controller = filterCommands.createGlobalSearchController({{
|
|
delay: 0,
|
|
search: (query, signal) => {{
|
|
signals.push(signal);
|
|
return new Promise((resolve, reject) => signal.addEventListener('abort', () => {{
|
|
const error = new Error('aborted');
|
|
error.name = 'AbortError';
|
|
reject(error);
|
|
}}));
|
|
}},
|
|
onState: state => states.push(state),
|
|
}});
|
|
controller.setQuery('mobile');
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
controller.setQuery('release');
|
|
if (!signals[0].aborted) throw new Error('superseded request was not aborted');
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
controller.setQuery('');
|
|
if (!signals[1].aborted) throw new Error('cleared query request was not aborted');
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
if (states.some(state => state.status === 'error')) throw new Error('abort rendered an error');
|
|
if (states.at(-1).status !== 'idle') throw new Error('clear did not restore idle 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_preserves_partial_result_status():
|
|
script = f"""
|
|
const filterCommands = require({json.dumps(str(COMMANDS))});
|
|
(async () => {{
|
|
const states = [];
|
|
const controller = filterCommands.createGlobalSearchController({{
|
|
delay: 0,
|
|
search: () => Promise.resolve({{ items:[{{ title:'Useful result' }}], partial:true }}),
|
|
onState: state => states.push(state),
|
|
}});
|
|
controller.setQuery('mobile');
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const ready = states.find(state => state.status === 'ready');
|
|
if (!ready || ready.items.length !== 1 || ready.partial !== true) {{
|
|
throw new Error('partial result metadata was lost: ' + JSON.stringify(states));
|
|
}}
|
|
}})().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_retries_partial_continuation_without_losing_results():
|
|
script = f"""
|
|
const filterCommands = require({json.dumps(str(COMMANDS))});
|
|
(async () => {{
|
|
const requests = [];
|
|
const states = [];
|
|
const controller = filterCommands.createGlobalSearchController({{
|
|
delay: 0,
|
|
search: (query, signal, page, scope, continuation) => {{
|
|
requests.push({{page, continuation}});
|
|
if (requests.length === 1) return Promise.resolve({{
|
|
items:[{{kind:'pull',repository:'a/b',number:2}}], partial:true,
|
|
has_more:true, next_page:2, continuation:{{issues:1,pulls:null}}, failed_streams:['issue']
|
|
}});
|
|
return Promise.resolve({{
|
|
items:[{{kind:'issue',repository:'a/b',number:1}}], partial:false,
|
|
has_more:false, next_page:2, continuation:{{issues:null,pulls:null}}, failed_streams:[]
|
|
}});
|
|
}},
|
|
onState: state => states.push(state),
|
|
}});
|
|
controller.setQuery('mobile');
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
await controller.loadMore();
|
|
const ready = states.filter(state => state.status === 'ready').at(-1);
|
|
if (JSON.stringify(requests[1].continuation) !== JSON.stringify({{issues:1,pulls:null}})) {{
|
|
throw new Error('retry did not preserve failed stream page: ' + JSON.stringify(requests));
|
|
}}
|
|
if (ready.partial || ready.items.length !== 2 || ready.items[0].kind !== 'pull' || ready.items[1].kind !== 'issue') {{
|
|
throw new Error('recovered results were not appended: ' + JSON.stringify(ready));
|
|
}}
|
|
}})().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_appends_deduplicated_pages_and_ignores_stale_load_more():
|
|
script = f"""
|
|
const filterCommands = require({json.dumps(str(COMMANDS))});
|
|
(async () => {{
|
|
const pending = new Map();
|
|
const states = [];
|
|
const controller = filterCommands.createGlobalSearchController({{
|
|
delay: 0,
|
|
search: (query, signal, page) => new Promise(resolve => pending.set(query + ':' + page, resolve)),
|
|
onState: state => states.push(state),
|
|
}});
|
|
controller.setQuery('mobile');
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
pending.get('mobile:1')({{ items:[{{kind:'issue',repository:'a/b',number:1}}], has_more:true, next_page:2 }});
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
controller.loadMore();
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
controller.setQuery('release');
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
pending.get('mobile:2')({{ items:[{{kind:'issue',repository:'a/b',number:1}},{{kind:'pull',repository:'a/b',number:2}}], has_more:false, next_page:3 }});
|
|
pending.get('release:1')({{ items:[{{kind:'issue',repository:'a/b',number:3}}], 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.query !== 'release' || ready.items.length !== 1 || ready.items[0].number !== 3) {{
|
|
throw new Error('stale page entered current query: ' + JSON.stringify(states));
|
|
}}
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
|
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
|
|
def test_command_selection_wraps_for_arrow_keys():
|
|
script = f"""
|
|
const commands = require({json.dumps(str(COMMANDS))});
|
|
if (commands.searchUrl('mobile', 2, {{kind:'all',state:'open'}}, {{issues:1,pulls:3}}) !==
|
|
'api/v1/search?q=mobile&limit=10&page=2&kind=all&state=open&issues_page=1&pulls_page=3') {{
|
|
throw new Error('independent continuation query was not encoded');
|
|
}}
|
|
if (commands.nextSelection(-1, 'ArrowDown', 3) !== 0) throw new Error('should select first');
|
|
if (commands.nextSelection(2, 'ArrowDown', 3) !== 0) throw new Error('should wrap down');
|
|
if (commands.nextSelection(0, 'ArrowUp', 3) !== 2) throw new Error('should wrap up');
|
|
if (commands.nextSelection(1, 'Enter', 3) !== 1) throw new Error('other keys should not move');
|
|
"""
|
|
|
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
|
|
def test_palette_exposes_accessible_global_work_search_under_dashboard_subpath():
|
|
html = dashboard_bundle_text()
|
|
|
|
assert 'role="combobox"' in html
|
|
assert 'aria-controls="cmd-results"' in html
|
|
assert 'role="listbox"' in html
|
|
assert "fetch(filterCommands.searchUrl(" 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 "Retry missing results" in html
|
|
assert 'id="cmd-load-more"' in html
|
|
assert urljoin(
|
|
"https://forge.alexanderwhitestone.com/dashboard/",
|
|
"api/v1/search?q=mobile",
|
|
) == "https://forge.alexanderwhitestone.com/dashboard/api/v1/search?q=mobile"
|
|
|
|
|
|
def test_search_preview_navigates_a_loaded_result_session_and_reports_position():
|
|
script = f"""
|
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
const items = [1, 2, 3].map(number => ({{repository:'stackchain/api', number, kind:'issue'}}));
|
|
const states = [];
|
|
const navigated = [];
|
|
const preview = createSearchPreview({{
|
|
fetchJson: item => Promise.resolve({{...item, title:'Issue ' + item.number}}),
|
|
mutate: () => Promise.resolve(),
|
|
getSession: () => ({{items, more:false}}),
|
|
loadMore: () => Promise.resolve(),
|
|
onNavigate: item => navigated.push(item.number),
|
|
onState: state => states.push(state),
|
|
}});
|
|
await preview.open(items[1]);
|
|
await preview.previous();
|
|
await preview.next();
|
|
const ready = states.filter(state => state.status === 'ready');
|
|
process.stdout.write(JSON.stringify({{
|
|
navigated,
|
|
positions:ready.map(state => state.navigation && [state.navigation.position, state.navigation.total,
|
|
state.navigation.hasPrevious, state.navigation.hasNext]),
|
|
current:ready.at(-1).detail.number,
|
|
}}));
|
|
}})().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) == {
|
|
"navigated": [1, 2],
|
|
"positions": [[2, 3, True, True], [1, 3, False, True], [2, 3, True, True]],
|
|
"current": 2,
|
|
}
|
|
|
|
|
|
def test_search_preview_reports_only_successfully_loaded_revisions_to_its_source_queue():
|
|
script = f"""
|
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
const loaded=[];
|
|
const preview=createSearchPreview({{
|
|
fetchJson:item => item.number === 9 ? Promise.reject(new Error('preview failed')) :
|
|
Promise.resolve({{...item,title:'Loaded'}}),
|
|
mutate:async()=>{{}}, onOpened:item=>loaded.push([item.number,item.updated_at]), onState:()=>{{}},
|
|
}});
|
|
await preview.open({{repository:'stackchain/api',number:42,kind:'issue',updated_at:'rev-1'}});
|
|
await preview.open({{repository:'stackchain/api',number:9,kind:'issue',updated_at:'rev-2'}}).catch(()=>{{}});
|
|
process.stdout.write(JSON.stringify(loaded));
|
|
}})().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) == [[42, "rev-1"]]
|
|
|
|
|
|
def test_search_preview_queue_and_next_advances_only_after_durable_admission():
|
|
script = f"""
|
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async()=>{{
|
|
const items=[1,2].map(number=>({{repository:'stackchain/api',number,kind:'issue'}}));
|
|
const values=new Map();let admit;const navigated=[];const states=[];let cleared=0;
|
|
const preview=createSearchPreview({{
|
|
fetchJson:async item=>item,mutate:async()=>{{}},
|
|
queueReply:(item,body,operationId)=>new Promise(resolve=>{{admit=()=>resolve({{id:operationId,queued:true}});}}),
|
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
|
createOperationId:()=> 'offline-search-1',hasAttachments:()=>false,clearAttachments:()=>{{cleared+=1;}},
|
|
getSession:()=>({{items,more:false}}),onNavigate:item=>navigated.push(item.number),onState:state=>states.push(state),
|
|
}});
|
|
await preview.open(items[0]);preview.saveReplyDraft('Queue safely.');
|
|
const pending=preview.reply({{advance:true}});await Promise.resolve();const before=[...navigated];admit();await pending;
|
|
process.stdout.write(JSON.stringify({{before,navigated,cleared,draft:values.size,
|
|
queued:states.some(state=>state.status==='queued')}}));
|
|
}})().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) == {
|
|
"before": [], "navigated": [2], "cleared": 1, "draft": 0, "queued": True
|
|
}
|
|
|
|
|
|
def test_search_preview_reply_retry_keeps_draft_and_operation_until_server_confirmation():
|
|
script = f"""
|
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
const item = {{repository:'stackchain/api', number:42, kind:'pull'}};
|
|
const values = new Map();
|
|
const storage = {{
|
|
getItem:key => values.has(key) ? values.get(key) : null,
|
|
setItem:(key,value) => values.set(key,value),
|
|
removeItem:key => values.delete(key),
|
|
}};
|
|
const calls = [];
|
|
const states = [];
|
|
let attempt = 0;
|
|
const preview = createSearchPreview({{
|
|
fetchJson: candidate => Promise.resolve({{...candidate, title:'Review reply'}}),
|
|
mutate: () => Promise.resolve(),
|
|
postReply: (candidate, body, operationId) => {{
|
|
calls.push([candidate.number, body, operationId]);
|
|
attempt += 1;
|
|
return attempt === 1 ? Promise.reject(new Error('offline')) : Promise.resolve({{
|
|
id:91, author:'timmy', body, created_at:'2026-08-14T12:30:00Z'
|
|
}});
|
|
}},
|
|
storage,
|
|
createOperationId: () => 'reply-operation-42',
|
|
onState: state => states.push(state),
|
|
}});
|
|
await preview.open(item);
|
|
preview.saveReplyDraft('Ready to merge.');
|
|
try {{ await preview.reply(); }} catch (_) {{}}
|
|
const draftAfterFailure = preview.replyDraft();
|
|
await preview.reply();
|
|
process.stdout.write(JSON.stringify({{
|
|
calls,
|
|
draftAfterFailure,
|
|
draftAfterSuccess:preview.replyDraft(),
|
|
comments:states.filter(state => state.conversation).at(-1).conversation.comments,
|
|
}}));
|
|
}})().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": [
|
|
[42, "Ready to merge.", "reply-operation-42"],
|
|
[42, "Ready to merge.", "reply-operation-42"],
|
|
],
|
|
"draftAfterFailure": "Ready to merge.",
|
|
"draftAfterSuccess": "",
|
|
"comments": [{
|
|
"id": 91,
|
|
"author": "timmy",
|
|
"body": "Ready to merge.",
|
|
"created_at": "2026-08-14T12:30:00Z",
|
|
}],
|
|
}
|
|
|
|
|
|
def test_search_preview_reply_and_next_advances_only_after_server_confirmation():
|
|
script = f"""
|
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
const items = [1, 2].map(number => ({{repository:'stackchain/api', number, kind:'issue'}}));
|
|
const values = new Map();
|
|
let confirm;
|
|
const navigated = [];
|
|
const preview = createSearchPreview({{
|
|
fetchJson:item => Promise.resolve(item), mutate:() => Promise.resolve(),
|
|
postReply:() => new Promise(resolve => {{ confirm = resolve; }}),
|
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
|
createOperationId:() => 'reply-next-1',
|
|
getSession:() => ({{items,more:false}}), loadMore:() => Promise.resolve(),
|
|
onNavigate:item => navigated.push(item.number), onState:() => {{}},
|
|
}});
|
|
await preview.open(items[0]);
|
|
preview.saveReplyDraft('Done.');
|
|
const pending = preview.reply({{advance:true}});
|
|
await Promise.resolve();
|
|
const before = [...navigated];
|
|
confirm({{id:7,author:'timmy',body:'Done.'}});
|
|
await pending;
|
|
process.stdout.write(JSON.stringify({{before,after:navigated,draft:preview.replyDraft()}}));
|
|
}})().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) == {
|
|
"before": [],
|
|
"after": [2],
|
|
"draft": "",
|
|
}
|
|
|
|
|
|
def test_search_preview_keeps_confirmed_reply_when_earlier_conversation_load_finishes_late():
|
|
script = f"""
|
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
const values = new Map();
|
|
let finishConversation;
|
|
const states = [];
|
|
const preview = createSearchPreview({{
|
|
fetchJson:item => Promise.resolve({{...item,title:'Race'}}),
|
|
fetchConversation:() => new Promise(resolve => {{ finishConversation = resolve; }}),
|
|
mutate:() => Promise.resolve(),
|
|
postReply:(_item,body) => Promise.resolve({{id:9,author:'timmy',body}}),
|
|
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
|
createOperationId:() => 'reply-race-9', onState:state => states.push(state),
|
|
}});
|
|
await preview.open({{repository:'stackchain/api',number:9,kind:'issue'}});
|
|
preview.saveReplyDraft('Confirmed reply');
|
|
await preview.reply();
|
|
finishConversation({{comments:[{{id:3,author:'alex',body:'Earlier'}}],older_page:null}});
|
|
await Promise.resolve();
|
|
const comments = states.filter(state => state.conversation?.status === 'ready').at(-1).conversation.comments;
|
|
process.stdout.write(JSON.stringify(comments.map(comment => comment.id)));
|
|
}})().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) == [3, 9]
|
|
|
|
|
|
def test_search_preview_next_loads_one_page_at_boundary_then_announces_completion():
|
|
script = f"""
|
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
let session = {{items:[1, 2].map(number => ({{repository:'stackchain/api', number, kind:'issue'}})), more:true}};
|
|
let loads = 0;
|
|
const states = [];
|
|
const preview = createSearchPreview({{
|
|
fetchJson: item => Promise.resolve(item), mutate: () => Promise.resolve(),
|
|
getSession: () => session,
|
|
loadMore: async () => {{
|
|
loads += 1;
|
|
session = {{items:[...session.items, {{repository:'stackchain/api', number:3, kind:'issue'}}], more:false}};
|
|
}},
|
|
onNavigate() {{}}, onState: state => states.push(state),
|
|
}});
|
|
await preview.open(session.items[1]);
|
|
await preview.next();
|
|
await preview.next();
|
|
const final = states.at(-1);
|
|
process.stdout.write(JSON.stringify({{loads, current:final.detail.number, complete:final.complete,
|
|
navigation:final.navigation}}));
|
|
}})().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) == {
|
|
"loads": 1,
|
|
"current": 3,
|
|
"complete": True,
|
|
"navigation": {"position": 3, "total": 3, "hasPrevious": True, "hasNext": False},
|
|
}
|
|
|
|
|
|
def test_remote_search_load_more_is_awaitable_for_preview_boundary_navigation():
|
|
script = f"""
|
|
const commands = require({json.dumps(str(COMMANDS))});
|
|
(async () => {{
|
|
const states = [];
|
|
const controller = commands.createGlobalSearchController({{
|
|
delay:0,
|
|
search: async (_query, _signal, page) => page === 1
|
|
? {{items:[{{kind:'issue',repository:'a/b',number:1}}], has_more:true, next_page:2}}
|
|
: {{items:[{{kind:'issue',repository:'a/b',number:2}}], has_more:false, next_page:3}},
|
|
onState: state => states.push(state),
|
|
}});
|
|
controller.setQuery('mobile');
|
|
await new Promise(resolve => setTimeout(resolve, 5));
|
|
const loaded = await controller.loadMore();
|
|
process.stdout.write(JSON.stringify({{numbers:loaded.items.map(item => item.number), more:loaded.more}}));
|
|
}})().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) == {"numbers": [1, 2], "more": False}
|
|
|
|
|
|
def test_mobile_search_preview_progressively_renders_a_retryable_paged_conversation():
|
|
html = dashboard_bundle_text()
|
|
css = (FRONTEND / "dashboard.css").read_text()
|
|
preview_source = SEARCH_PREVIEW.read_text()
|
|
|
|
assert 'id="search-preview-conversation"' in html
|
|
assert 'id="search-preview-comments"' in html
|
|
assert 'id="search-preview-conversation-status"' in html
|
|
assert 'id="retry-search-preview-conversation"' in html
|
|
assert 'id="load-older-search-preview-comments"' in html
|
|
assert 'aria-live="polite"' in html
|
|
assert "fetchConversation:" in html
|
|
assert "api.retryConversation()" in preview_source
|
|
assert "api.loadOlderConversation()" in preview_source
|
|
assert "renderMarkdown(comment.body || '')" in preview_source
|
|
assert ".search-preview-conversation" in css
|
|
assert ".search-preview-comment" in css
|
|
assert "min-height:44px" in css
|
|
|
|
|
|
def test_mobile_search_preview_renders_session_navigation_and_advances_after_planning():
|
|
html = dashboard_bundle_text()
|
|
css = (FRONTEND / "dashboard.css").read_text()
|
|
|
|
assert 'id="previous-search-result"' in html
|
|
assert 'id="search-preview-position"' in html
|
|
assert 'id="next-search-result"' in html
|
|
assert 'aria-label="Search result navigation"' in html
|
|
assert ".search-preview-navigation" in css
|
|
assert ".search-preview-header button, .search-preview-actions button" in css
|
|
assert "getSession:() => followingQueue.session() || commandSearchState" in html
|
|
preview_source = SEARCH_PREVIEW.read_text()
|
|
assert "api.previous()" in preview_source
|
|
assert "api.next()" in preview_source
|
|
|
|
defer_flow = html.split("if (context === 'search')", 1)[1].split(
|
|
"if (context === 'detail')", 1
|
|
)[0]
|
|
queue_flow = html.split(
|
|
"qs('#queue-search-result').addEventListener('click'", 1
|
|
)[1].split("qs('#start-search-result')", 1)[0]
|
|
assert "next()" in defer_flow
|
|
assert "next()" in queue_flow
|
|
assert "closeSearchPreview()" not in defer_flow
|
|
assert "if (outcome === 'queued') await next()" in queue_flow
|
|
|
|
|
|
def test_search_preview_ignores_stale_result_details():
|
|
script = f"""
|
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
const pending = new Map();
|
|
const states = [];
|
|
const preview = createSearchPreview({{
|
|
fetchJson: item => new Promise(resolve => pending.set(item.number, resolve)),
|
|
mutate: () => Promise.resolve(),
|
|
onState: state => states.push(state),
|
|
}});
|
|
preview.open({{ repository:'stackchain/api', number:1, kind:'issue' }});
|
|
preview.open({{ repository:'stackchain/api', number:2, kind:'issue' }});
|
|
pending.get(2)({{ number:2, title:'Current' }});
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
pending.get(1)({{ number:1, title:'Stale' }});
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const ready = states.filter(state => state.status === 'ready');
|
|
if (ready.length !== 1 || ready[0].detail.number !== 2) {{
|
|
throw new Error('stale detail replaced current preview: ' + JSON.stringify(ready));
|
|
}}
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
|
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
|
|
def test_search_preview_publishes_detail_before_progressive_conversation_and_ignores_stale_comments():
|
|
script = f"""
|
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
const pending = new Map();
|
|
const states = [];
|
|
const preview = createSearchPreview({{
|
|
fetchJson: item => Promise.resolve({{...item, title:'Issue ' + item.number}}),
|
|
fetchConversation: item => new Promise(resolve => pending.set(item.number, resolve)),
|
|
mutate: () => Promise.resolve(),
|
|
onState: state => states.push(state),
|
|
}});
|
|
await preview.open({{repository:'stackchain/api', number:1, kind:'issue'}});
|
|
const firstReady = states.find(state => state.status === 'ready' && state.detail?.number === 1);
|
|
if (!firstReady || firstReady.conversation?.status !== 'loading') throw new Error('detail waited for conversation');
|
|
await preview.open({{repository:'stackchain/api', number:2, kind:'issue'}});
|
|
pending.get(1)({{comments:[{{id:1,body:'stale'}}], page:1, older_page:null}});
|
|
pending.get(2)({{comments:[{{id:2,body:'current'}}], page:1, older_page:null}});
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
const final = states.at(-1);
|
|
process.stdout.write(JSON.stringify({{
|
|
number:final.detail.number,
|
|
status:final.conversation.status,
|
|
comments:final.conversation.comments.map(comment => comment.body),
|
|
}}));
|
|
}})().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) == {
|
|
"number": 2,
|
|
"status": "ready",
|
|
"comments": ["current"],
|
|
}
|
|
|
|
|
|
def test_search_preview_retries_failed_conversation_and_prepends_older_messages_without_duplicates():
|
|
script = f"""
|
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
let calls = 0;
|
|
const states = [];
|
|
const preview = createSearchPreview({{
|
|
fetchJson: item => Promise.resolve({{...item, title:'Issue'}}),
|
|
fetchConversation: (_item, page) => {{
|
|
calls += 1;
|
|
if (calls === 1) return Promise.reject(new Error('offline'));
|
|
if (page === 1) return Promise.resolve({{comments:[{{id:1,body:'old'}},{{id:3,body:'duplicate'}}], older_page:null}});
|
|
return Promise.resolve({{comments:[{{id:3,body:'newer'}},{{id:4,body:'newest'}}], older_page:1}});
|
|
}},
|
|
mutate: () => Promise.resolve(),
|
|
onState: state => states.push(state),
|
|
}});
|
|
await preview.open({{repository:'stackchain/api', number:42, kind:'issue'}});
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
if (states.at(-1).conversation.status !== 'error') throw new Error('failure unavailable');
|
|
await preview.retryConversation();
|
|
await preview.loadOlderConversation();
|
|
const final = states.at(-1).conversation;
|
|
process.stdout.write(JSON.stringify({{
|
|
calls,
|
|
status:final.status,
|
|
ids:final.comments.map(comment => comment.id),
|
|
olderPage:final.olderPage,
|
|
}}));
|
|
}})().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": 3,
|
|
"status": "ready",
|
|
"ids": [1, 3, 4],
|
|
"olderPage": None,
|
|
}
|
|
|
|
|
|
def test_search_preview_claim_is_single_flight_and_reports_confirmation():
|
|
script = f"""
|
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
let claims = 0;
|
|
let resolveClaim;
|
|
const states = [];
|
|
const preview = createSearchPreview({{
|
|
fetchJson: item => Promise.resolve(item),
|
|
mutate: (_detail, action) => {{ if (action !== 'claim') throw new Error('wrong action'); claims += 1; return new Promise(resolve => {{ resolveClaim = resolve; }}); }},
|
|
onState: state => states.push(state),
|
|
}});
|
|
await preview.open({{ repository:'stackchain/api', number:42, kind:'issue' }});
|
|
const detail = {{ repository:'stackchain/api', number:42, claimable:true }};
|
|
const first = preview.claim(detail);
|
|
const second = preview.claim(detail);
|
|
if (claims !== 1 || first !== second) throw new Error('claim was not single-flight');
|
|
resolveClaim({{ assignees:['timmy'] }});
|
|
await first;
|
|
if (!states.some(state => state.status === 'claimed')) throw new Error('claim confirmation missing');
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
|
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
|
|
def test_search_preview_reopen_is_single_flight_and_reports_confirmation():
|
|
script = f"""
|
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
let reopens = 0;
|
|
let resolveReopen;
|
|
const states = [];
|
|
const preview = createSearchPreview({{
|
|
fetchJson: item => Promise.resolve(item),
|
|
mutate: (_detail, action) => {{ if (action !== 'reopen') throw new Error('wrong action'); reopens += 1; return new Promise(resolve => {{ resolveReopen = resolve; }}); }},
|
|
onState: state => states.push(state),
|
|
}});
|
|
const detail = {{ repository:'stackchain/api', number:42, reopenable:true }};
|
|
await preview.open(detail);
|
|
const first = preview.reopen(detail);
|
|
const second = preview.reopen(detail);
|
|
if (reopens !== 1 || first !== second) throw new Error('reopen was not single-flight');
|
|
resolveReopen({{ state:'open', assignees:['timmy'] }});
|
|
await first;
|
|
if (!states.some(state => state.status === 'reopened')) throw new Error('reopen confirmation missing');
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
|
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
|
|
def test_search_preview_closed_authored_pull_reopen_sends_head_guard_to_pull_api():
|
|
script = f"""
|
|
require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
const calls = [];
|
|
const mutate = globalThis.searchPreviewMutation(async (path, options) => {{
|
|
calls.push({{path, options}});
|
|
return {{repository:'stackchain/web',number:9,state:'open',head_sha:'abc1234'}};
|
|
}});
|
|
const detail = {{
|
|
repository:'stackchain/web',number:9,kind:'pull',state:'closed',
|
|
authored_pull_reopenable:true,head_sha:'abc1234',
|
|
}};
|
|
const result = await mutate(detail, 'reopen-pull');
|
|
process.stdout.write(JSON.stringify({{calls,result}}));
|
|
}})().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": [{
|
|
"path": "api/v1/repos/stackchain/web/pulls/9/reopen",
|
|
"options": {
|
|
"method": "PATCH",
|
|
"headers": {"Content-Type": "application/json"},
|
|
"body": '{"expected_head_sha":"abc1234"}',
|
|
},
|
|
}],
|
|
"result": {
|
|
"repository": "stackchain/web",
|
|
"number": 9,
|
|
"state": "open",
|
|
"head_sha": "abc1234",
|
|
},
|
|
}
|
|
|
|
|
|
def test_search_preview_closed_authored_pull_exposes_reopen_in_my_work_action():
|
|
script = f"""
|
|
require({json.dumps(str(SEARCH_PREVIEW))});
|
|
const detail = {{
|
|
repository:'stackchain/web',number:9,kind:'pull',state:'closed',
|
|
authored_pull_reopenable:true,head_sha:'abc1234',
|
|
}};
|
|
const ready = {{hidden:true,textContent:'',disabled:false}};
|
|
globalThis.renderSearchPreviewStart(detail, {{status:'ready'}}, ready);
|
|
const pending = {{hidden:true,textContent:'',disabled:false}};
|
|
globalThis.renderSearchPreviewStart(detail, {{status:'reopening'}}, pending);
|
|
process.stdout.write(JSON.stringify({{ready,pending}}));
|
|
"""
|
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
|
|
|
assert result.returncode == 0, result.stderr
|
|
assert json.loads(result.stdout) == {
|
|
"ready": {
|
|
"hidden": False,
|
|
"textContent": "Reopen in My Work",
|
|
"disabled": False,
|
|
},
|
|
"pending": {
|
|
"hidden": False,
|
|
"textContent": "Reopen in My Work",
|
|
"disabled": True,
|
|
},
|
|
}
|
|
|
|
|
|
def test_search_preview_closed_authored_pull_reopen_is_single_flight():
|
|
script = f"""
|
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
let calls = 0;
|
|
let resolveMutation;
|
|
const states = [];
|
|
const detail = {{
|
|
repository:'stackchain/web',number:9,kind:'pull',state:'closed',
|
|
authored_pull_reopenable:true,head_sha:'abc1234',
|
|
}};
|
|
const preview = createSearchPreview({{
|
|
fetchJson:async () => detail,
|
|
mutate:(_detail, action) => {{
|
|
if (action !== 'reopen-pull') throw new Error('wrong action');
|
|
calls += 1;
|
|
return new Promise(resolve => {{ resolveMutation = resolve; }});
|
|
}},
|
|
onState:state => states.push(state),
|
|
}});
|
|
await preview.open(detail);
|
|
const first = preview.reopenPull(detail);
|
|
const second = preview.reopenPull(detail);
|
|
if (first !== second || calls !== 1) throw new Error('pull recovery was not single-flight');
|
|
resolveMutation({{...detail,state:'open'}});
|
|
await first;
|
|
process.stdout.write(JSON.stringify({{calls,status:states.at(-1).status}}));
|
|
}})().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": "reopened"}
|
|
|
|
|
|
def test_authored_pull_recovery_confirms_refreshes_and_opens_above_search_history():
|
|
script = f"""
|
|
require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
const events = [];
|
|
const history = ['search'];
|
|
const detail = {{repository:'stackchain/web',number:9,kind:'pull',head_sha:'abc1234'}};
|
|
const item = {{repository:'stackchain/web',number:9,kind:'pull',key:'stackchain/web#9'}};
|
|
const recover = globalThis.createSearchAuthoredPullRecovery({{
|
|
confirm:message => {{ events.push(['confirm',message]); return true; }},
|
|
reopen:target => {{ events.push(['reopen',target.head_sha]); return Promise.resolve({{...target,state:'open'}}); }},
|
|
refresh:() => {{ events.push(['refresh']); return Promise.resolve(); }},
|
|
find:target => target.number === 9 ? item : null,
|
|
open:opened => {{ events.push(['open',opened.key]); history.push('pull'); }},
|
|
unavailable:message => events.push(['unavailable',message]),
|
|
}});
|
|
const outcome = await recover(detail);
|
|
process.stdout.write(JSON.stringify({{events,history,outcome}}));
|
|
}})().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["events"] == [
|
|
["confirm", "Reopen stackchain/web #9?"],
|
|
["reopen", "abc1234"],
|
|
["refresh"],
|
|
["open", "stackchain/web#9"],
|
|
]
|
|
assert payload["history"] == ["search", "pull"]
|
|
assert payload["outcome"] == "opened"
|
|
|
|
|
|
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_search_preview_preserves_authoritative_watch_and_offers_following_repair():
|
|
script = f"""
|
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
const states = [];
|
|
const detail = {{repository:'stackchain/api',number:42,kind:'pull',state:'open',claimable:true,watching:false}};
|
|
const preview = createSearchPreview({{
|
|
fetchJson:async()=>detail,
|
|
watch:async()=>({{watching:true,following_synced:false,error:'Watching in Gitea, but Following could not sync. Retry this action.'}}),
|
|
onState:state=>states.push(state),
|
|
}});
|
|
await preview.open(detail);
|
|
await preview.setWatching(true);
|
|
const final=states.at(-1);
|
|
process.stdout.write(JSON.stringify({{
|
|
status:final.status,
|
|
watching:final.detail.watching,
|
|
message:globalThis.searchPreviewWatchStatus(final),
|
|
}}));
|
|
}})().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) == {
|
|
"status": "watch-partial",
|
|
"watching": True,
|
|
"message": "Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch pull request to repair.",
|
|
}
|
|
|
|
|
|
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_watch_is_available_for_open_issues_pulls_and_closed_following_retirement():
|
|
script = f"""
|
|
require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
const loaded = [];
|
|
const options = globalThis.searchPreviewSubscriptionOptions(async path => {{
|
|
loaded.push(path);
|
|
return {{watching:true}};
|
|
}});
|
|
const assigned = {{repository:'stackchain/api',number:42,kind:'issue',state:'open',
|
|
claimable:false,assignees:['alexander']}};
|
|
const hydrated = await options.load(assigned);
|
|
const button = {{hidden:true,textContent:'',disabled:false}};
|
|
globalThis.renderSearchPreviewWatch(hydrated, {{status:'ready'}}, button);
|
|
const excluded = [];
|
|
for (const detail of [
|
|
{{...assigned,state:'closed'}},
|
|
{{...assigned,kind:'pull'}},
|
|
]) {{
|
|
await options.load(detail);
|
|
const candidate = {{hidden:false,textContent:'',disabled:false}};
|
|
globalThis.renderSearchPreviewWatch(detail, {{status:'ready'}}, candidate);
|
|
excluded.push(candidate.hidden);
|
|
}}
|
|
const closedFollowing = await options.load({{...assigned,state:'closed',following:true}});
|
|
const retire = {{hidden:true,textContent:'',disabled:false}};
|
|
globalThis.renderSearchPreviewWatch(closedFollowing, {{status:'ready'}}, retire);
|
|
process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,excluded,closedFollowing,retire}}));
|
|
}})().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["loaded"] == [
|
|
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
|
|
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=pull",
|
|
]
|
|
assert payload["watching"] is True
|
|
assert payload["button"] == {
|
|
"hidden": False,
|
|
"textContent": "Stop watching",
|
|
"disabled": False,
|
|
}
|
|
assert payload["excluded"] == [True, False]
|
|
assert payload["closedFollowing"]["watching"] is True
|
|
assert payload["retire"] == {
|
|
"hidden": False,
|
|
"textContent": "Stop watching & next",
|
|
"disabled": False,
|
|
}
|
|
|
|
|
|
def test_closed_following_unwatch_is_single_flight_and_advances_only_after_confirmation():
|
|
script = f"""
|
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
let finish;
|
|
const states=[]; const retired=[]; const opened=[]; let calls=0;
|
|
const next={{repository:'stackchain/web',number:9,kind:'issue',state:'open'}};
|
|
const preview=createSearchPreview({{
|
|
fetchJson:item => Promise.resolve(item), mutate:()=>Promise.resolve(),
|
|
watch:() => {{ calls += 1; return new Promise(resolve => finish=resolve); }},
|
|
afterUnwatch:item => {{ retired.push(item.number); return next; }},
|
|
onNavigate:item => opened.push(item.number), onState:state => states.push(state),
|
|
}});
|
|
await preview.open({{repository:'stackchain/api',number:42,kind:'issue',state:'closed',following:true,watching:true}});
|
|
const first=preview.setWatching(false); const second=preview.setWatching(false);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
const before={{calls,retired:[...retired],opened:[...opened]}};
|
|
finish({{watching:false,following_synced:true}});
|
|
await Promise.all([first,second]);
|
|
process.stdout.write(JSON.stringify({{before,calls,same:first===second,retired,opened,last:states.at(-1)}}));
|
|
}})().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["before"] == {"calls": 1, "retired": [], "opened": []}
|
|
assert payload["same"] is True
|
|
assert payload["retired"] == [42]
|
|
assert payload["opened"] == [9]
|
|
assert payload["last"]["detail"]["number"] == 9
|
|
|
|
|
|
def test_search_subscription_preview_hydrates_detail_before_watch_state():
|
|
script = f"""
|
|
require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
const calls=[];
|
|
const options=globalThis.searchPreviewSubscriptionOptions(async path => {{
|
|
calls.push(path);
|
|
if (path.includes('/subscription')) return {{watching:true}};
|
|
return {{repository:'stackchain/api',number:42,kind:'issue',state:'open',title:'Hydrated'}};
|
|
}});
|
|
const detail=await options.preview({{repository:'stackchain/api',number:42,kind:'issue'}});
|
|
process.stdout.write(JSON.stringify({{calls,detail}}));
|
|
}})().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": [
|
|
"api/v1/repos/stackchain/api/issues/42/preview?kind=issue",
|
|
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
|
|
],
|
|
"detail": {
|
|
"repository": "stackchain/api", "number": 42, "kind": "issue",
|
|
"state": "open", "title": "Hydrated", "watching": True,
|
|
},
|
|
}
|
|
|
|
|
|
def test_closed_following_preview_preserves_origin_and_skips_status_lookup():
|
|
script = f"""
|
|
require({json.dumps(str(SEARCH_PREVIEW))});
|
|
(async () => {{
|
|
const calls=[];
|
|
const options=globalThis.searchPreviewSubscriptionOptions(async path => {{
|
|
calls.push(path);
|
|
return {{repository:'stackchain/api',number:42,kind:'issue',state:'closed',title:'Finished'}};
|
|
}});
|
|
const detail=await options.preview({{repository:'stackchain/api',number:42,kind:'issue',following:true}});
|
|
process.stdout.write(JSON.stringify({{calls,detail}}));
|
|
}})().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["calls"] == [
|
|
"api/v1/repos/stackchain/api/issues/42/preview?kind=issue"
|
|
]
|
|
assert payload["detail"]["following"] is True
|
|
assert payload["detail"]["watching"] is 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()
|
|
|
|
run_item = html.split("function runCommandItem(item)", 1)[1].split(
|
|
"function renderCommands", 1
|
|
)[0]
|
|
assert "searchPreview.open(item.result)" in run_item
|
|
assert "window.location.assign" not in run_item
|
|
remote_branch = run_item.split("} else {", 1)[1]
|
|
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()
|
|
|
|
assert "claimButton.hidden = !(detail.claimable || detail.assigned_to_me)" in html
|
|
assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html
|
|
assert "openPreviewWorkInMyWork" in html
|
|
|
|
|
|
def test_assigned_pull_preview_hands_off_to_existing_my_work_sheet_without_claiming():
|
|
html = dashboard_bundle_text()
|
|
|
|
assert "claimButton.hidden = !(detail.claimable || detail.assigned_to_me)" in html
|
|
assert "async function openPreviewWorkInMyWork(detail)" in html
|
|
handoff = html.split("async function openPreviewWorkInMyWork(detail)", 1)[1].split(
|
|
"function runCommandItem", 1
|
|
)[0]
|
|
assert "candidate.kind === detail.kind" in handoff
|
|
assert "candidate.key === detail.repository + '#' + detail.number" in handoff
|
|
|
|
click_handler = html.split(
|
|
"qs('#claim-search-result').addEventListener('click'", 1
|
|
)[1].split("qs('#start-search-result')", 1)[0]
|
|
assert "if (claimed.claimable) await searchPreview.claim(claimed)" in click_handler
|
|
assert "await openPreviewWorkInMyWork(claimed)" in click_handler
|
|
|
|
|
|
def test_requested_pull_preview_opens_native_review_without_assignment_mutation():
|
|
html = dashboard_bundle_text()
|
|
css = (FRONTEND / "dashboard.css").read_text()
|
|
preview = SEARCH_PREVIEW.read_text()
|
|
|
|
assert "renderSearchPreviewStart(detail, state, startButton)" in html
|
|
assert "button.hidden = !(detail.reviewable" in preview
|
|
assert "detail.reviewable ? 'Review now'" in preview
|
|
handler = html.split(
|
|
"qs('#start-search-result').addEventListener('click'", 1
|
|
)[1].split("qs('#close-whiteboard')", 1)[0]
|
|
assert "detail.kind = 'review'" in handler
|
|
assert "openRoutedWork" in handler
|
|
assert "{ replace:true }" in handler
|
|
assert "searchPreview.claim" not in handler
|
|
assert "fetchReviewJson" not in handler
|
|
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
|
|
|
|
|
|
def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
|
html = dashboard_bundle_text()
|
|
css = (FRONTEND / "dashboard.css").read_text()
|
|
preview = SEARCH_PREVIEW.read_text()
|
|
|
|
assert 'id="start-search-result"' in html
|
|
assert "(detail.assigned_to_me ? 'Start in Today' : 'Assign & start')" in preview
|
|
assert "const searchAssignAndStart = createSearchStart(detail => searchPreview.claim(detail))" in html
|
|
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
|
|
assert ".search-preview-primary-actions" in css
|
|
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
|
|
|
|
|
def test_mobile_search_pauses_today_and_offers_a_lossless_return():
|
|
html = dashboard_bundle_text()
|
|
css = (FRONTEND / "dashboard.css").read_text()
|
|
|
|
assert html.count('<aside class="search-today-interruption" data-search-today-interruption') == 2
|
|
assert html.count('<button data-return-from-search type="button">') == 2
|
|
assert "taskOverlayHistory.leave();" in html
|
|
assert ".search-today-interruption" in css
|
|
assert ".search-today-interruption button { min-height:44px;" in css
|
|
|
|
|
|
def test_search_preview_queues_eligible_issue_and_continues_preserved_search():
|
|
html = dashboard_bundle_text()
|
|
css = (FRONTEND / "dashboard.css").read_text()
|
|
|
|
assert 'id="queue-search-result"' in html
|
|
assert "queueButton.hidden = !(detail.kind === 'issue'" in html
|
|
assert "detail.assigned_to_me ? 'Add to Today' : 'Assign & add to Today'" in html
|
|
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
|
|
"const searchAssignAndStart", 1
|
|
)[0]
|
|
assert "queue: confirmed => queueToday(acceptClaimedIssue(confirmed))" in orchestrator
|
|
handler = html.split("qs('#queue-search-result').addEventListener('click'", 1)[1].split(
|
|
"qs('#start-search-result')", 1
|
|
)[0]
|
|
assert "destination: 'queue'" in handler
|
|
assert "alreadyOwned: detail.assigned_to_me" in handler
|
|
assert "next()" in handler
|
|
assert "closeSearchPreview()" not in handler
|
|
assert "mobileSearchViewport.restoreScroll()" in html
|
|
assert "@media (max-width:420px)" in css
|
|
assert ".search-preview-primary-actions { grid-template-columns:1fr; }" in css
|
|
|
|
|
|
def test_search_queue_confirmation_is_accessible_and_existing_today_item_is_not_readded():
|
|
html = dashboard_bundle_text()
|
|
|
|
assert 'id="cmd-search-action-status"' in html
|
|
assert 'aria-live="polite"' in html
|
|
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
|
|
"const searchAssignAndStart", 1
|
|
)[0]
|
|
assert "qs('#cmd-search-action-status').textContent = message" in orchestrator
|
|
handler = html.split("qs('#queue-search-result').addEventListener('click'", 1)[1].split(
|
|
"qs('#start-search-result')", 1
|
|
)[0]
|
|
assert "todayWork.contains(detail)" in handler
|
|
assert "Already in Today." in handler
|
|
assert handler.index("todayWork.contains(detail)") < handler.index("searchAssignAndStart.run")
|
|
|
|
|
|
def test_search_defer_claims_once_then_schedules_the_canonical_issue_without_touching_today():
|
|
script = f"""
|
|
const createSearchDefer = require({json.dumps(str(SEARCH_DEFER))});
|
|
(async () => {{
|
|
const calls = [];
|
|
let releaseClaim;
|
|
const flow = createSearchDefer({{
|
|
claim: detail => {{
|
|
calls.push(['claim', detail.number]);
|
|
return new Promise(resolve => releaseClaim = resolve);
|
|
}},
|
|
accept: confirmed => {{
|
|
calls.push(['accept', confirmed.key]);
|
|
return {{kind:'issue', repository:confirmed.repository, number:confirmed.number, key:confirmed.key}};
|
|
}},
|
|
defer: (item, until) => {{ calls.push(['defer', item.key, until.toISOString()]); return 'deferred'; }},
|
|
refresh: () => calls.push(['refresh']),
|
|
announce: message => calls.push(['announce', message]),
|
|
formatTime: value => value.toISOString(),
|
|
}});
|
|
const detail = {{kind:'issue', repository:'stackchain/api', number:42, claimable:true}};
|
|
const until = new Date('2026-08-15T09:00:00.000Z');
|
|
const first = flow.run(detail, until);
|
|
const second = flow.run(detail, until);
|
|
if (first !== second) throw new Error('confirmation was not single-flight');
|
|
releaseClaim({{kind:'issue', repository:'stackchain/api', number:42, key:'stackchain/api#42'}});
|
|
const outcome = await first;
|
|
process.stdout.write(JSON.stringify({{outcome, calls}}));
|
|
}})().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) == {
|
|
"outcome": "deferred",
|
|
"calls": [
|
|
["claim", 42],
|
|
["accept", "stackchain/api#42"],
|
|
["defer", "stackchain/api#42", "2026-08-15T09:00:00.000Z"],
|
|
["refresh"],
|
|
["announce", "Assigned and deferred until 2026-08-15T09:00:00.000Z."],
|
|
],
|
|
}
|
|
|
|
|
|
def test_search_preview_opens_later_picker_before_claiming_an_eligible_issue():
|
|
html = dashboard_bundle_text()
|
|
css = (FRONTEND / "dashboard.css").read_text()
|
|
|
|
assert 'id="defer-search-result"' in html
|
|
assert "const deferButton = qs('#defer-search-result')" in html
|
|
assert "deferButton.hidden = !(detail.kind === 'issue'" in html
|
|
assert "detail.assigned_to_me ? 'Defer' : 'Assign & defer'" in html
|
|
handler = html.split("qs('#defer-search-result').addEventListener('click'", 1)[1].split(
|
|
"qs('#queue-search-result')", 1
|
|
)[0]
|
|
assert "laterPicker.open(detail, event.currentTarget, 'search')" in handler
|
|
assert "searchPreview.claim" not in handler
|
|
assert ".search-preview-actions button, .search-preview-actions a { min-height:44px;" in css
|
|
assert "@media (max-width:420px)" in css
|
|
assert ".search-preview-primary-actions { grid-template-columns:1fr; }" in css
|
|
|
|
|
|
def test_search_defer_reports_recoverable_partial_outcome_when_later_storage_fails_after_claim():
|
|
script = f"""
|
|
const createSearchDefer = require({json.dumps(str(SEARCH_DEFER))});
|
|
(async () => {{
|
|
const messages = [];
|
|
let refreshes = 0;
|
|
const flow = createSearchDefer({{
|
|
claim: () => Promise.resolve({{kind:'issue', repository:'stackchain/api', number:42}}),
|
|
accept: issue => issue,
|
|
defer: () => 'unavailable',
|
|
refresh: () => refreshes += 1,
|
|
announce: message => messages.push(message),
|
|
formatTime: value => value.toISOString(),
|
|
}});
|
|
const outcome = await flow.run({{kind:'issue', repository:'stackchain/api', number:42, claimable:true}},
|
|
new Date('2026-08-15T09:00:00.000Z'));
|
|
process.stdout.write(JSON.stringify({{outcome, refreshes, messages}}));
|
|
}})().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) == {
|
|
"outcome": "unavailable",
|
|
"refreshes": 1,
|
|
"messages": [
|
|
"Assignment succeeded, but Later could not be saved. Open this issue in My Work to retry."
|
|
],
|
|
}
|
|
|
|
|
|
def test_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
|
|
html = dashboard_bundle_text()
|
|
preview = SEARCH_PREVIEW.read_text()
|
|
|
|
assert "detail.reopenable ? 'Reopen & resume'" in preview
|
|
assert "mutate:searchPreviewMutation(fetchReviewJson)" in html
|
|
handler = html.split("qs('#start-search-result').addEventListener('click'", 1)[1].split(
|
|
"qs('#close-whiteboard')", 1
|
|
)[0]
|
|
assert "searchReopenAndStart.run(detail)" in handler
|
|
orchestrator = html.split("function createSearchStart(claim)", 1)[1].split(
|
|
"const searchAssignAndStart", 1
|
|
)[0]
|
|
assert "available: createAndStart.available" in orchestrator
|
|
assert "claim," in orchestrator
|
|
assert "acceptClaimedIssue(confirmed)" in orchestrator
|
|
assert "const searchReopenAndStart = createSearchStart(detail => searchPreview.reopen(detail))" in html
|
|
|
|
|
|
def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners():
|
|
script = f"""
|
|
const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))});
|
|
const listeners = new Map();
|
|
const viewport = {{
|
|
height: 430, offsetTop: 17,
|
|
addEventListener: (name, fn) => listeners.set(name, fn),
|
|
removeEventListener: (name, fn) => {{ if (listeners.get(name) === fn) listeners.delete(name); }},
|
|
}};
|
|
const values = new Map();
|
|
const palette = {{ style: {{
|
|
setProperty: (name, value) => values.set(name, value),
|
|
removeProperty: name => values.delete(name),
|
|
}} }};
|
|
const results = {{ scrollTop: 73 }};
|
|
const controller = createMobileSearchViewport({{
|
|
palette, results, viewport, mediaQuery: {{ matches: true }},
|
|
schedule: fn => fn(),
|
|
}});
|
|
controller.open();
|
|
if (values.get('--search-viewport-top') !== '17px') throw new Error('viewport offset was not applied');
|
|
if (values.get('--search-viewport-height') !== '430px') throw new Error('viewport height was not applied');
|
|
if (listeners.size !== 2) throw new Error('viewport listeners were not attached once');
|
|
controller.rememberScroll();
|
|
results.scrollTop = 0;
|
|
controller.restoreScroll();
|
|
if (results.scrollTop !== 73) throw new Error('result scroll position was not restored');
|
|
controller.open();
|
|
if (listeners.size !== 2) throw new Error('duplicate viewport listeners were attached');
|
|
controller.close();
|
|
if (listeners.size !== 0 || values.size !== 0) throw new Error('viewport state leaked after close');
|
|
"""
|
|
|
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
|
|
|
|
def test_mobile_search_is_a_keyboard_safe_workspace_wired_to_preview_history():
|
|
html = dashboard_bundle_text()
|
|
css = (FRONTEND / "dashboard.css").read_text()
|
|
|
|
assert 'class="cmd-palette-header"' in html
|
|
assert 'id="close-command-palette"' in html
|
|
assert 'style="margin-top:8px;max-height:min(65vh,520px);overflow-y:auto;"' not in html
|
|
assert 'src="static/mobile-search-viewport.js"' in html
|
|
assert "height:var(--search-viewport-height,100dvh)" in css
|
|
assert "top:var(--search-viewport-top,0px)" in css
|
|
assert "#cmd-results { flex:1; min-height:0; overflow-y:auto;" in css
|
|
assert "mobileSearchViewport.rememberScroll()" in html
|
|
assert "mobileSearchViewport.restoreScroll()" in html
|
|
assert "mobileSearchViewport.open()" in html
|
|
assert "mobileSearchViewport.close()" in html
|