stackchain-dashboard/tests/test_today_progress.py
timmy 688eadf17a
All checks were successful
CI / lint (pull_request) Successful in 2m29s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m1s
CI / release-candidate (pull_request) Has been skipped
feat: dictate active Today progress updates (Closes #1058)
2026-08-18 03:05:53 +00:00

289 lines
14 KiB
Python

import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
TODAY_PROGRESS = ROOT / "frontend" / "today-progress.js"
def run_node(script: str) -> dict:
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_progress_drafts_are_account_and_item_scoped_bounded_and_durable():
script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
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),
}};
let login = 'Timmy';
const progress = createProgress({{storage, getLogin:() => login, admit:async () => ({{}}), maxLength:20}});
const issue = {{identity:'issue:stackchain/dashboard:12:', kind:'issue', repository:'stackchain/dashboard', number:12}};
const pull = {{identity:'pull:stackchain/dashboard:13:', kind:'pull', repository:'stackchain/dashboard', number:13}};
const saved = progress.save(issue.identity, ' Investigated latency ');
progress.save(pull.identity, 'Reviewing tests');
const restored = createProgress({{storage, getLogin:() => login, admit:async () => ({{}}), maxLength:20}});
login = 'alexander';
const otherAccount = restored.load(issue.identity);
login = 'timmy';
process.stdout.write(JSON.stringify({{
saved,
issue:restored.load(issue.identity),
pull:restored.load(pull.identity),
otherAccount,
tooLong:restored.save(issue.identity, 'x'.repeat(21)),
keys:[...values.keys()],
}}));
"""
output = run_node(script)
assert output["saved"] is True
assert output["issue"] == "Investigated latency"
assert output["pull"] == "Reviewing tests"
assert output["otherAccount"] == ""
assert output["tooLong"] is False
assert output["keys"] == ["stackchain.today-progress.v1.timmy"]
def test_post_admits_exact_comment_once_and_clears_only_after_durable_admission():
script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
const values = new Map();
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const calls=[]; let fail=true;
const progress=createProgress({{
storage, getLogin:()=> 'timmy', makeId:()=> 'progress-op-1',
admit:async message => {{ calls.push(message); if (fail) throw new Error('offline store unavailable'); return {{background:true,item:{{id:message.operationId}}}}; }},
}});
const target={{identity:'issue:stackchain/dashboard:12:',kind:'issue',repository:'stackchain/dashboard',number:12}};
progress.save(target.identity, 'Shipped the first slice');
let error='';
try {{ await progress.post(target); }} catch (caught) {{ error=caught.message; }}
const retained=progress.load(target.identity);
fail=false;
const admitted=await progress.post(target);
process.stdout.write(JSON.stringify({{error,retained,after:progress.load(target.identity),calls,admitted}}));
"""
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
assert output["error"] == "offline store unavailable"
assert output["retained"] == "Shipped the first slice"
assert output["after"] == ""
assert len(output["calls"]) == 2
assert output["calls"][0] == output["calls"][1] == {
"kind": "issue-comment",
"repository": "stackchain/dashboard",
"number": 12,
"body": "Shipped the first slice",
"operationId": "progress-op-1",
}
assert output["admitted"]["background"] is True
def test_photo_only_progress_is_durably_admitted_and_retained_until_success():
script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
const values = new Map();
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const photo = {{filename:'result.jpg',contentType:'image/jpeg',blob:{{size:42}},operationId:'photo-op-1'}};
const calls=[]; let fail=true;
const progress=createProgress({{
storage, getLogin:()=> 'timmy', makeId:()=> 'progress-photo-op-1',
admit:async message => {{ calls.push(message); if (fail) throw new Error('photo store unavailable'); return {{background:true}}; }},
}});
const target={{identity:'issue:stackchain/dashboard:12:',kind:'issue',repository:'stackchain/dashboard',number:12}};
let error='';
try {{ await progress.post(target, '', [photo]); }} catch (caught) {{ error=caught.message; }}
const retained=progress.load(target.identity);
fail=false;
await progress.post(target, undefined, [photo]);
process.stdout.write(JSON.stringify({{error,retained,after:progress.load(target.identity),calls}}));
"""
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
assert output["error"] == "photo store unavailable"
assert output["retained"] == ""
assert output["after"] == ""
assert len(output["calls"]) == 2
assert output["calls"][0] == output["calls"][1] == {
"kind": "issue-comment",
"repository": "stackchain/dashboard",
"number": 12,
"body": "",
"operationId": "progress-photo-op-1",
"attachments": [{
"filename": "result.jpg",
"contentType": "image/jpeg",
"blob": {"size": 42},
"operationId": "photo-op-1",
}],
}
def test_photo_payload_changes_rotate_the_progress_operation_identity():
script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
const values = new Map(); const ids = ['progress-1', 'progress-2', 'progress-3'];
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const progress=createProgress({{storage,getLogin:()=> 'timmy',makeId:()=>ids.shift(),admit:async()=>({{}})}});
const identity='issue:stackchain/dashboard:12:';
const before={{filename:'proof.jpg',contentType:'image/jpeg',note:'Before',operationId:'photo-1'}};
const replacement={{filename:'proof.jpg',contentType:'image/jpeg',note:'After',operationId:'photo-2'}};
progress.save(identity, 'Repair complete', [before]);
const first=JSON.parse(values.values().next().value).drafts[identity].operation_id;
progress.save(identity, 'Repair complete', [before]);
const unchanged=JSON.parse(values.values().next().value).drafts[identity].operation_id;
progress.save(identity, 'Repair complete', [replacement]);
const replaced=JSON.parse(values.values().next().value).drafts[identity].operation_id;
replacement.note='Redacted replacement';
progress.save(identity, 'Repair complete', [replacement]);
const recaptioned=JSON.parse(values.values().next().value).drafts[identity].operation_id;
process.stdout.write(JSON.stringify({{first,unchanged,replaced,recaptioned}}));
"""
output = run_node(script)
assert output == {
"first": "progress-1",
"unchanged": "progress-1",
"replaced": "progress-2",
"recaptioned": "progress-3",
}
def test_photo_cleanup_failure_resumes_cleanup_without_readmitting_delivery():
script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
const values=new Map();
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const calls=[]; let cleanupFails=true; let cleanupCalls=0;
const options={{storage,getLogin:()=> 'timmy',makeId:()=> 'stable-progress-op',admit:async message=>{{calls.push(message);return {{background:true}};}}}};
let progress=createProgress(options);
const target={{identity:'pull:stackchain/dashboard:22:',kind:'pull',repository:'stackchain/dashboard',number:22}};
const photo={{filename:'proof.webp',contentType:'image/webp',blob:{{size:9}},operationId:'photo-op'}};
const cleanup=async()=>{{cleanupCalls++;if(cleanupFails)throw new Error('draft cleanup blocked')}};
let error=''; let deliveryAdmitted=false;
try {{await progress.post(target,'',[photo],cleanup);}} catch(caught){{error=caught.message;deliveryAdmitted=caught.deliveryAdmitted===true;}}
progress=createProgress(options);
cleanupFails=false;
const recovered=await progress.post(target,'edited text must not be admitted',[{{...photo,operationId:'replacement'}}],cleanup);
process.stdout.write(JSON.stringify({{error,deliveryAdmitted,calls,cleanupCalls,recovered,after:progress.load(target.identity)}}));
"""
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
assert output["error"] == "Progress update is already queued; photo cleanup is pending."
assert output["deliveryAdmitted"] is True
assert len(output["calls"]) == 1
assert output["cleanupCalls"] == 2
assert output["recovered"]["alreadyAdmitted"] is True
assert output["after"] == ""
def test_progress_rejects_inactive_or_unsupported_targets_without_mutation():
script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
const values=new Map(); let admitted=0;
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const progress=createProgress({{storage,getLogin:()=> 'timmy',admit:async()=>{{admitted++;}}}});
const results=[];
for (const target of [null, {{identity:'review:r:1:',kind:'review',repository:'r',number:1}}, {{identity:'issue:r:0:',kind:'issue',repository:'r',number:0}}]) {{
try {{ await progress.post(target, 'note'); results.push('accepted'); }} catch (error) {{ results.push(error.message); }}
}}
process.stdout.write(JSON.stringify({{results,admitted,keys:[...values.keys()]}}));
"""
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
assert output["admitted"] == 0
assert output["keys"] == []
assert output["results"] == [
"An active Today issue or pull request is required.",
"An active Today issue or pull request is required.",
"An active Today issue or pull request is required.",
]
def test_progress_sheet_scopes_voice_to_the_open_item_and_aborts_on_close():
script = f"""
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
class Element {{
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=false;this.listeners={{}}; }}
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
click() {{ return this.listeners.click?.(); }}
showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{}}
}}
const selectors=['#today-progress-sheet','#today-progress-body','#today-progress-status','[data-mobile-today-update]',
'#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress'];
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
const target={{identity:'issue:stackchain/dashboard:1058:',kind:'issue',repository:'stackchain/dashboard',number:1058,label:'#1058',title:'Voice progress'}};
const calls=[];
const voice={{open:async identity=>calls.push(['open',identity]),cancel:()=>calls.push(['cancel'])}};
const view=createView({{
progress:{{load:()=> 'Saved text',save:()=>true}},currentTarget:()=>target,qs:selector=>elements[selector],voice,
photos:{{open:async()=>calls.push(['photos'])}},
}});
(async()=>{{
await elements['[data-mobile-today-update]'].click();
const opened={{body:elements['#today-progress-body'].value,sheet:elements['#today-progress-sheet'].open,calls:[...calls]}};
await elements['#cancel-today-progress'].click();
process.stdout.write(JSON.stringify({{opened,closed:!elements['#today-progress-sheet'].open,calls}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert run_node(script) == {
"opened": {
"body": "Saved text",
"sheet": True,
"calls": [["open", "issue:stackchain/dashboard:1058:"], ["photos"]],
},
"closed": True,
"calls": [["open", "issue:stackchain/dashboard:1058:"], ["photos"], ["cancel"]],
}
def test_mobile_progress_sheet_is_accessible_bundled_and_safe_area_aware():
html = (ROOT / "frontend" / "index.html").read_text()
css = (ROOT / "frontend" / "dashboard.css").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
assert 'data-mobile-today-update' in html
assert 'id="today-progress-sheet"' in html
assert 'aria-labelledby="today-progress-title"' in html
assert 'id="today-progress-body"' in html
for control in (
"voice-today-progress",
"start-voice-today-progress",
"stop-voice-today-progress",
"voice-today-progress-review",
"voice-today-progress-transcript",
"append-voice-today-progress",
"replace-with-voice-today-progress",
"discard-voice-today-progress",
"voice-today-progress-status",
):
assert f'id="{control}"' in html
assert 'for="voice-today-progress-transcript"' in html
assert 'data-draft-label="progress update"' in html
assert 'id="take-today-progress-photo"' in html
assert 'id="today-progress-attachment"' in html
assert 'id="today-progress-attachment-preview"' in html
assert 'maxlength="2000"' in html
assert 'id="save-today-progress"' in html
assert 'id="post-today-progress"' in html
assert '<script src="static/today-progress.js"></script>' in html
assert '"static/today-progress.js"' in bundle
assert "workSession.target('continue')" in dashboard
assert "authoredOutbox.enqueueDurably" in dashboard
assert "createTodayProgressPhotos" in dashboard
assert "scope:'today-progress'" in TODAY_PROGRESS.read_text()
assert "lanes:{ today:" in TODAY_PROGRESS.read_text()
assert "photos:todayProgressPhotos" in dashboard
assert "['today-progress', '#today-progress-body']" in dashboard
assert "voice:todayProgressVoice" in dashboard
assert "const controller = issueAttachment.mount" in TODAY_PROGRESS.read_text()
assert "const checkpointAttachments = await photos?.serialize?.() || [];" in TODAY_PROGRESS.read_text()
assert "progress.save(openedTarget.identity, body.value, checkpointAttachments)" in TODAY_PROGRESS.read_text()
assert "error.deliveryAdmitted" in TODAY_PROGRESS.read_text()
assert ".today-progress-panel" in css
assert "env(safe-area-inset-bottom)" in css
assert ".today-progress-actions button" in css and "min-height:44px" in css
assert ".today-progress-panel .voice-conversation-controls button" in css