386 lines
16 KiB
Python
386 lines
16 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"
|
|
|
|
|
|
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_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.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, page = 1)" in html
|
|
assert "signal," in html
|
|
assert "Some results are temporarily unavailable." 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_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_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_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 "(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_closed_issue_preview_reopens_then_resumes_through_capacity_guard():
|
|
html = dashboard_bundle_text()
|
|
|
|
assert "startButton.textContent = 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
|