238 lines
8.8 KiB
Python
238 lines
8.8 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from tests.dashboard_bundle import dashboard
|
|
|
|
|
|
ROOT = Path(__file__).parents[1]
|
|
READINESS = ROOT / "frontend" / "today-readiness.js"
|
|
SERVICE_WORKER = ROOT / "frontend" / "service-worker.js"
|
|
|
|
|
|
def run_node(script):
|
|
return subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout
|
|
|
|
|
|
def test_readiness_opens_ready_target_without_inspecting_the_rest_of_the_queue():
|
|
script = f"""
|
|
const createTodayReadiness = require({json.dumps(str(READINESS))});
|
|
const calls = [];
|
|
const inspections = [];
|
|
const item = (number, kind='issue') => ({{kind, repository:'stackchain/dashboard', number, title:'Item ' + number}});
|
|
const readiness = createTodayReadiness({{
|
|
inspect: async candidate => {{ inspections.push(candidate.number); return {{available:true, dependencies:[]}}; }},
|
|
onOpen: (action, candidate) => calls.push(['open', action, candidate.number]),
|
|
onGate: state => calls.push(['gate', state.status]),
|
|
}});
|
|
readiness.run('start', [item(1), item(2), item(3)]).then(result =>
|
|
process.stdout.write(JSON.stringify({{result, calls, inspections}}))
|
|
);
|
|
"""
|
|
assert json.loads(run_node(script)) == {
|
|
"result": "opened",
|
|
"calls": [["open", "start", 1]],
|
|
"inspections": [1],
|
|
}
|
|
|
|
|
|
def test_readiness_checks_fallbacks_concurrently_but_preserves_queue_order():
|
|
script = f"""
|
|
const createTodayReadiness = require({json.dumps(str(READINESS))});
|
|
const items = [1, 2, 3].map(number => ({{kind:'issue', repository:'r', number}}));
|
|
const starts = [];
|
|
const resolvers = {{}};
|
|
const readiness = createTodayReadiness({{
|
|
inspect: candidate => {{
|
|
starts.push(candidate.number);
|
|
if (candidate.number === 1) return Promise.resolve({{available:true, dependencies:[{{state:'open'}}]}});
|
|
return new Promise(resolve => {{ resolvers[candidate.number] = resolve; }});
|
|
}},
|
|
onOpen: () => undefined,
|
|
onGate: state => {{ globalThis.gated = state.nextReady.number; }},
|
|
}});
|
|
(async () => {{
|
|
const running = readiness.run('start', items);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
const startedTogether = starts.slice();
|
|
if (resolvers[3]) resolvers[3]({{available:true, dependencies:[]}});
|
|
resolvers[2]({{available:true, dependencies:[]}});
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
if (!startedTogether.includes(3)) resolvers[3]({{available:true, dependencies:[]}});
|
|
const result = await running;
|
|
process.stdout.write(JSON.stringify({{result, startedTogether, gated:globalThis.gated}}));
|
|
}})();
|
|
"""
|
|
assert json.loads(run_node(script)) == {
|
|
"result": "gated",
|
|
"startedTogether": [1, 2, 3],
|
|
"gated": 2,
|
|
}
|
|
|
|
|
|
def test_readiness_gates_blocked_target_and_offers_earliest_ready_without_reordering():
|
|
script = f"""
|
|
const createTodayReadiness = require({json.dumps(str(READINESS))});
|
|
const calls = [];
|
|
const items = [1, 2, 3].map(number => ({{kind:'issue', repository:'r', number, title:'Issue ' + number}}));
|
|
const readiness = createTodayReadiness({{
|
|
inspect: async candidate => candidate.number === 1
|
|
? {{available:true, dependencies:[{{repository:'r', number:99, title:'Prerequisite', state:'open', url:'https://forge.example/r/issues/99'}}]}}
|
|
: {{available:true, dependencies:[]}},
|
|
onOpen: (action, candidate) => calls.push(['open', action, candidate.number]),
|
|
onGate: state => calls.push(['gate', state.status, state.target.number, state.nextReady.number, state.items.map(item => item.number)]),
|
|
}});
|
|
readiness.run('resume', items, items[0]).then(async result => {{
|
|
const opened = readiness.startNextReady();
|
|
process.stdout.write(JSON.stringify({{result, opened, calls, order:items.map(item => item.number)}}));
|
|
}});
|
|
"""
|
|
assert json.loads(run_node(script)) == {
|
|
"result": "gated",
|
|
"opened": True,
|
|
"calls": [
|
|
["gate", "blocked", 1, 2, [1, 2, 3]],
|
|
["open", "resume", 2],
|
|
],
|
|
"order": [1, 2, 3],
|
|
}
|
|
|
|
|
|
def test_readiness_previews_only_a_current_blocker_without_closing_or_reordering_gate():
|
|
script = f"""
|
|
const createTodayReadiness = require({json.dumps(str(READINESS))});
|
|
const previews = [];
|
|
const items = [1, 2].map(number => ({{kind:'issue', repository:'r', number}}));
|
|
const blocker = {{repository:'r', number:99, title:'Prerequisite', state:'open'}};
|
|
const readiness = createTodayReadiness({{
|
|
inspect: async candidate => candidate.number === 1
|
|
? {{available:true, dependencies:[blocker]}}
|
|
: {{available:true, dependencies:[]}},
|
|
onPreview: candidate => previews.push(candidate.number),
|
|
}});
|
|
(async () => {{
|
|
await readiness.run('start', items, items[0]);
|
|
const accepted = readiness.previewBlocker(blocker);
|
|
const rejected = readiness.previewBlocker({{repository:'r', number:100}});
|
|
const snapshot = readiness.snapshot();
|
|
process.stdout.write(JSON.stringify({{
|
|
accepted, rejected, previews,
|
|
open:snapshot.status === 'blocked',
|
|
target:snapshot.target.number,
|
|
order:snapshot.items.map(item => item.number),
|
|
}}));
|
|
}})();
|
|
"""
|
|
assert json.loads(run_node(script)) == {
|
|
"accepted": True,
|
|
"rejected": False,
|
|
"previews": [99],
|
|
"open": True,
|
|
"target": 1,
|
|
"order": [1, 2],
|
|
}
|
|
|
|
|
|
def test_readiness_never_calls_unknown_ready_and_supports_retry_or_explicit_override():
|
|
script = f"""
|
|
const createTodayReadiness = require({json.dumps(str(READINESS))});
|
|
const calls = [];
|
|
const target = {{kind:'issue', repository:'r', number:1}};
|
|
let available = false;
|
|
const readiness = createTodayReadiness({{
|
|
inspect: async () => available ? {{available:true, dependencies:[]}} : {{available:false, dependencies:[]}},
|
|
onOpen: (action, candidate) => calls.push(['open', action, candidate.number]),
|
|
onGate: state => calls.push(['gate', state.status]),
|
|
}});
|
|
readiness.run('next', [target], target).then(async result => {{
|
|
available = true;
|
|
const retried = await readiness.retry();
|
|
available = false;
|
|
await readiness.run('continue', [target], target);
|
|
const overridden = readiness.workAnyway();
|
|
process.stdout.write(JSON.stringify({{result, retried, overridden, calls}}));
|
|
}});
|
|
"""
|
|
assert json.loads(run_node(script)) == {
|
|
"result": "gated",
|
|
"retried": "opened",
|
|
"overridden": True,
|
|
"calls": [
|
|
["gate", "unknown"],
|
|
["open", "next", 1],
|
|
["gate", "unknown"],
|
|
["open", "continue", 1],
|
|
],
|
|
}
|
|
|
|
|
|
def test_readiness_treats_non_issue_work_as_runnable_without_dependency_io():
|
|
script = f"""
|
|
const createTodayReadiness = require({json.dumps(str(READINESS))});
|
|
let inspections = 0;
|
|
const pull = {{kind:'pull', repository:'r', number:7}};
|
|
const readiness = createTodayReadiness({{
|
|
inspect: async () => {{ inspections += 1; throw new Error('not expected'); }},
|
|
onOpen: () => undefined,
|
|
onGate: () => undefined,
|
|
}});
|
|
readiness.run('start', [pull]).then(result =>
|
|
process.stdout.write(JSON.stringify({{result, inspections}}))
|
|
);
|
|
"""
|
|
assert json.loads(run_node(script)) == {"result": "opened", "inspections": 0}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_dashboard_exposes_mobile_today_readiness_gate_and_runtime():
|
|
html = await dashboard()
|
|
|
|
assert '<script src="static/today-readiness.js"></script>' in html
|
|
assert 'id="today-readiness-sheet"' in html
|
|
assert 'id="today-readiness-next"' in html
|
|
assert 'id="today-readiness-anyway"' in html
|
|
assert 'id="today-readiness-retry"' in html
|
|
assert 'id="today-readiness-plan"' in html
|
|
assert "createTodayReadiness({" in html
|
|
assert "runTodayTransition('start'" in html
|
|
assert "runTodayTransition('resume'" in html
|
|
assert "runTodayTransition('continue'" in html
|
|
assert "runTodayTransition('next'" in html
|
|
assert "runTodayTransition('complete'" in html
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate():
|
|
html = await dashboard()
|
|
|
|
assert 'data-today-blocker-index="' in html
|
|
assert "todayReadiness.previewBlocker(blocker)" in html
|
|
assert "searchPreview.open({ ...blocker, kind:'issue' })" in html
|
|
assert "taskOverlayHistory.open('search-preview')" in html
|
|
assert "suspendTodayReadiness()" in html
|
|
assert "todayReadiness.cancel()" in html
|
|
assert "todayReadinessBlockerFocus" in html
|
|
|
|
|
|
def test_readiness_runtime_is_available_in_offline_shell():
|
|
service_worker = SERVICE_WORKER.read_text()
|
|
|
|
assert "const CACHE = 'stackchain-dashboard-shell-v78';" in service_worker
|
|
assert "BASE + 'static/today-readiness.js'" in service_worker
|
|
|
|
|
|
def test_mobile_readiness_gate_has_touch_safe_wrapping_actions():
|
|
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
|
|
|
assert ".today-readiness-actions button" in css
|
|
assert "min-height:44px" in css
|
|
assert ".today-readiness-item" in css
|
|
assert "overflow-wrap:anywhere" in css
|
|
assert "env(safe-area-inset-bottom)" in css
|
|
assert ".today-readiness-blocker" in css
|
|
assert "min-height:44px" in css
|