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" 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_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_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_command_selection_wraps_for_arrow_keys(): script = f""" const commands = require({json.dumps(str(COMMANDS))}); 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('api/v1/search?q='" in html assert "async function searchGlobalWork(query, signal)" in html assert "signal," in html assert "Some results are temporarily unavailable." 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_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)), claim: () => 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_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), claim: () => {{ 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_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_assigned_issue_preview_hands_off_to_existing_my_work_sheet(): html = dashboard_bundle_text() assert "detail.claimable || (detail.assigned_to_me && detail.kind === 'issue')" in html assert "claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me'" in html assert "openPreviewIssueInMyWork" in html 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 "startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'" in html assert "const searchAssignAndStart = createAssignAndStart({" 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_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