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_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 "session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})]" 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_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()

    assert "startButton.hidden = !(detail.reviewable" in html
    assert "detail.reviewable ? 'Review now'" in html
    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()

    assert 'id="start-search-result"' in html
    assert "(detail.assigned_to_me ? 'Start in Today' : 'Assign & start')" in html
    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()

    assert "detail.reopenable ? 'Reopen & resume'" in html
    assert "mutate: (detail, action) => 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
