198 lines
9.5 KiB
Python
198 lines
9.5 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_cleanup_failure_retries_with_the_same_comment_operation():
|
|
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;
|
|
const progress=createProgress({{storage,getLogin:()=> 'timmy',makeId:()=> 'stable-progress-op',admit:async message=>{{calls.push(message);return {{background:true}};}}}});
|
|
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()=>{{if(cleanupFails)throw new Error('draft cleanup blocked')}};
|
|
let error='';
|
|
try {{await progress.post(target,'',[photo],cleanup);}} catch(caught){{error=caught.message;}}
|
|
cleanupFails=false;
|
|
await progress.post(target,undefined,[photo],cleanup);
|
|
process.stdout.write(JSON.stringify({{error,calls,after:progress.load(target.identity)}}));
|
|
"""
|
|
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
|
|
assert output["error"] == "draft cleanup blocked"
|
|
assert [call["operationId"] for call in output["calls"]] == ["stable-progress-op", "stable-progress-op"]
|
|
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_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
|
|
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 "const controller = issueAttachment.mount" 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
|