412 lines
24 KiB
Python
412 lines
24 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
SOURCE = Path(__file__).parents[1] / "frontend" / "release-receipt.js"
|
|
|
|
|
|
def run_node(body: str) -> dict:
|
|
script = f"const createReleaseReceipt=require({json.dumps(str(SOURCE))});\n" + body
|
|
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
return json.loads(completed.stdout)
|
|
|
|
|
|
def test_receipt_survives_reload_for_confirmed_account_only_and_tracks_exact_commit():
|
|
output = run_node(r"""
|
|
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 calls=[];
|
|
const first=createReleaseReceipt({storage,getLogin:()=>login,fetchJson:async path=>{calls.push(path);return {commit_sha:'merge456',ci_state:'pending',checks:[{name:'browser',state:'pending',url:''}],release:null};}});
|
|
const captured=first.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
|
|
const restored=createReleaseReceipt({storage,getLogin:()=>login,fetchJson:first.fetchJson}).restore();
|
|
restored && first.refresh().then(status=>{
|
|
login='alex';
|
|
const other=createReleaseReceipt({storage,getLogin:()=>login,fetchJson:first.fetchJson}).restore();
|
|
process.stdout.write(JSON.stringify({captured,restored,status,other,calls,keys:[...values.keys()]}));
|
|
});
|
|
""")
|
|
|
|
assert output["captured"]["commit_sha"] == "merge456"
|
|
assert output["restored"][0]["repository"] == "stackchain/api"
|
|
assert output["status"][0]["label"] == "Checks running"
|
|
assert output["status"][0]["checks"] == ["browser"]
|
|
assert output["other"] == []
|
|
assert output["calls"] == ["api/v1/repos/stackchain/api/release-receipt/merge456"]
|
|
assert output["keys"] == ["stackchain.release-receipt.v1:timmy"]
|
|
|
|
|
|
def test_release_receipt_deletes_the_exact_captured_source_branch_without_stopping_release_tracking():
|
|
output = run_node(r"""
|
|
const values=new Map(), calls=[];
|
|
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
|
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',confirmAction:()=>true,fetchJson:async(path,options={})=>{
|
|
calls.push({path,method:options.method||'GET',body:options.body||null});
|
|
if ((options.method||'GET') === 'DELETE') return {number:7,deleted:true,source_branch:'timmy/feature',source_head_sha:'abc1234'};
|
|
return {commit_sha:'merge456',ci_state:'pending',checks:[],release:null};
|
|
}});
|
|
const captured=receipt.capture(
|
|
{repository:'stackchain/api',number:7,key:'stackchain/api#7'},
|
|
{merge_commit_sha:'merge456',source_branch:'timmy/feature',source_head_sha:'abc1234',source_repository:'stackchain/api'}
|
|
);
|
|
(async()=>{
|
|
await receipt.deleteBranch('stackchain/api','merge456');
|
|
await receipt.refresh();
|
|
process.stdout.write(JSON.stringify({captured,calls,restored:receipt.restore()}));
|
|
})();
|
|
""")
|
|
|
|
assert output["captured"]["source_branch"] == "timmy/feature"
|
|
assert output["calls"][0] == {
|
|
"path": "api/v1/repos/stackchain/api/pulls/7/source-branch",
|
|
"method": "DELETE",
|
|
"body": json.dumps({
|
|
"source_branch": "timmy/feature",
|
|
"expected_head_sha": "abc1234",
|
|
}, separators=(",", ":")),
|
|
}
|
|
restored = output["restored"][0]
|
|
assert restored["cleanup"] == {"state": "deleted", "message": "Source branch deleted."}
|
|
assert restored["status"]["label"] == "Checks running"
|
|
|
|
|
|
def test_receipt_distinguishes_failed_checks_waiting_for_release_and_exact_release():
|
|
output = run_node(r"""
|
|
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 responses=[
|
|
{commit_sha:'merge456',ci_state:'failure',checks:[{name:'unit',state:'success'},{name:'browser',state:'failure'}],release:null},
|
|
{commit_sha:'merge456',ci_state:'success',checks:[{name:'browser',state:'success'}],release:null},
|
|
{commit_sha:'merge456',ci_state:'success',checks:[{name:'browser',state:'success'}],release:{tag:'rc-42',url:'https://forge.example/git/x/releases/tag/rc-42',assets:[{name:'manifest.json',url:'https://forge.example/manifest'}]}},
|
|
];
|
|
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async()=>responses.shift()});
|
|
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
|
|
(async()=>{
|
|
const failed=await receipt.refresh(); const waiting=await receipt.refresh(); const released=await receipt.refresh();
|
|
receipt.dismiss();
|
|
process.stdout.write(JSON.stringify({failed,waiting,released,restored:receipt.restore()}));
|
|
})();
|
|
""")
|
|
|
|
assert output["failed"][0]["label"] == "Checks failed"
|
|
assert output["failed"][0]["checks"] == ["browser"]
|
|
assert output["waiting"][0]["label"] == "Checks passed · waiting for release"
|
|
assert output["released"][0]["label"] == "Released · rc-42"
|
|
assert output["released"][0]["release"]["assets"][0]["name"] == "manifest.json"
|
|
assert output["restored"] == []
|
|
|
|
|
|
def test_failed_release_check_loads_exact_commit_diagnostics_and_retries_single_flight():
|
|
output = run_node(r"""
|
|
const values=new Map(), calls=[];
|
|
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
|
let finishRetry;
|
|
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',confirmAction:()=>true,fetchJson:async(path,options={})=>{
|
|
calls.push({path,method:options.method||'GET'});
|
|
if (path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'AssertionError: failed'};
|
|
if (path.endsWith('/retry')) return await new Promise(resolve=>{finishRetry=()=>resolve({commit_sha:'abc1234',status:'queued'});});
|
|
return {commit_sha:'abc1234',ci_state:'failure',checks:[{name:'CI / browser',state:'failure',description:'Playwright failed',url:'https://forge.example/git/stackchain/api/actions/runs/91/jobs/3',recovery:{run_id:91,job_index:3}}],release:null};
|
|
}});
|
|
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'abc1234'});
|
|
(async()=>{
|
|
const failed=(await receipt.refresh())[0];
|
|
const detail=await receipt.reviewFailure('stackchain/api','abc1234',91,3);
|
|
const first=receipt.retryFailure('stackchain/api','abc1234',91,3);
|
|
const second=receipt.retryFailure('stackchain/api','abc1234',91,3);
|
|
await new Promise(resolve=>setTimeout(resolve,0)); finishRetry();
|
|
const results=await Promise.all([first,second]);
|
|
process.stdout.write(JSON.stringify({failed,detail,results,calls,after:receipt.restore()[0].status}));
|
|
})();
|
|
""")
|
|
|
|
assert output["failed"]["failures"] == [{
|
|
"name": "CI / browser",
|
|
"description": "Playwright failed",
|
|
"url": "https://forge.example/git/stackchain/api/actions/runs/91/jobs/3",
|
|
"recovery": {"run_id": 91, "job_index": 3},
|
|
}]
|
|
assert output["detail"]["commit_sha"] == "abc1234"
|
|
assert output["detail"]["excerpt"] == "AssertionError: failed"
|
|
assert output["results"] == [True, True]
|
|
assert [call["method"] for call in output["calls"]] == ["GET", "GET", "POST"]
|
|
assert output["calls"][1]["path"].endswith(
|
|
"/pulls/7/release-receipt/abc1234/checks/91/jobs/3/failure"
|
|
)
|
|
assert output["calls"][2]["path"].endswith("/checks/91/jobs/3/retry")
|
|
assert output["after"]["label"] == "Checks running"
|
|
assert output["after"]["checks"] == []
|
|
|
|
|
|
def test_failed_release_row_renders_phone_recovery_with_exact_sha_log_and_job_fallback():
|
|
output = run_node(r"""
|
|
function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',className:'',disabled:false,append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(k,fn){this[k]=fn}}; }
|
|
global.document={createElement:tag=>node(tag)};
|
|
const values=new Map(), listNode=node();
|
|
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
|
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',listNode,confirmAction:()=>true,fetchJson:async path=>{
|
|
if (path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'AssertionError: mobile viewport overflow'};
|
|
return {commit_sha:'abc1234',ci_state:'failure',checks:[{name:'CI / browser',state:'failure',description:'Playwright failed',url:'https://forge.example/git/stackchain/api/actions/runs/91/jobs/3',recovery:{run_id:91,job_index:3}}]};
|
|
}});
|
|
const flatten=root=>[root,...root.children.flatMap(flatten)];
|
|
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'abc1234'});
|
|
(async()=>{
|
|
await receipt.refresh();
|
|
const review=flatten(listNode).find(value=>value.textContent==='Review failure');
|
|
await review.click();
|
|
const nodes=flatten(listNode);
|
|
process.stdout.write(JSON.stringify({
|
|
reviewLabel:review['aria-label'],
|
|
texts:nodes.map(value=>value.textContent).filter(Boolean),
|
|
links:nodes.filter(value=>value.tag==='a').map(value=>({text:value.textContent,href:value.href})),
|
|
classes:nodes.map(value=>value.className).filter(Boolean),
|
|
}));
|
|
})();
|
|
""")
|
|
|
|
assert output["reviewLabel"] == "Review failed check CI / browser for abc1234"
|
|
assert "Merge abc1234" in output["texts"]
|
|
assert "AssertionError: mobile viewport overflow" in output["texts"]
|
|
assert "Retry failed check" in output["texts"]
|
|
assert output["links"] == [{
|
|
"text": "Open job",
|
|
"href": "https://forge.example/git/stackchain/api/actions/runs/91/jobs/3",
|
|
}]
|
|
assert "release-failure-recovery" in output["classes"]
|
|
|
|
|
|
def test_diagnosed_failed_release_prepares_one_rollback_pull_and_opens_mobile_workspace():
|
|
output = run_node(r"""
|
|
function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',className:'',disabled:false,append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(k,fn){this[k]=fn}}; }
|
|
global.document={createElement:tag=>node(tag)};
|
|
const values=new Map(), calls=[], opened=[], listNode=node(), dialog={open:true,close(){this.open=false}};
|
|
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
|
const receipt=createReleaseReceipt({
|
|
storage,getLogin:()=> 'timmy',listNode,dialog,confirmAction:()=>true,openPull:pull=>opened.push(pull),
|
|
fetchJson:async(path,options={})=>{
|
|
calls.push({path,method:options.method||'GET'});
|
|
if (path.endsWith('/failure')) return {commit_sha:'abc1234',run_id:91,job_index:3,name:'CI / browser',excerpt:'AssertionError: failed'};
|
|
if (path.endsWith('/rollback')) return {repository:'stackchain/api',number:9,draft:true,url:'https://forge.example/git/stackchain/api/pulls/9',rollback_of:'abc1234'};
|
|
return {commit_sha:'abc1234',ci_state:'failure',checks:[{name:'CI / browser',state:'failure',recovery:{run_id:91,job_index:3}}],release:null};
|
|
},
|
|
});
|
|
const flatten=root=>[root,...root.children.flatMap(flatten)];
|
|
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'abc1234'});
|
|
(async()=>{
|
|
await receipt.refresh();
|
|
const before=flatten(listNode).filter(value=>value.textContent==='Prepare rollback PR').length;
|
|
await flatten(listNode).find(value=>value.textContent==='Review failure').click();
|
|
const rollback=flatten(listNode).find(value=>value.textContent==='Prepare rollback PR');
|
|
if (rollback) await rollback.click();
|
|
process.stdout.write(JSON.stringify({before,found:!!rollback,calls,opened,dialogOpen:dialog.open,status:receipt.restore()[0].rollback||null}));
|
|
})();
|
|
""")
|
|
|
|
assert output["before"] == 0
|
|
assert output["found"] is True
|
|
assert output["calls"][-1] == {
|
|
"path": "api/v1/repos/stackchain/api/pulls/7/release-receipt/abc1234/rollback",
|
|
"method": "POST",
|
|
}
|
|
assert output["opened"] == [{
|
|
"repository": "stackchain/api",
|
|
"number": 9,
|
|
"draft": True,
|
|
"url": "https://forge.example/git/stackchain/api/pulls/9",
|
|
"rollback_of": "abc1234",
|
|
"kind": "pull",
|
|
"key": "stackchain/api#9",
|
|
"state": "open",
|
|
}]
|
|
assert output["dialogOpen"] is False
|
|
assert output["status"]["state"] == "prepared"
|
|
assert output["status"]["number"] == 9
|
|
|
|
|
|
def test_watchlist_preserves_distinct_merges_deduplicates_and_dismisses_one():
|
|
output = run_node(r"""
|
|
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 receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async()=>{throw new Error('unused')}});
|
|
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
|
|
receipt.capture({repository:'stackchain/web',number:8,key:'stackchain/web#8'}, {merge_commit_sha:'merge789'});
|
|
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
|
|
const before=receipt.restore();
|
|
receipt.dismiss('stackchain/api', 'merge456');
|
|
const after=receipt.restore();
|
|
process.stdout.write(JSON.stringify({before,after,stored:JSON.parse([...values.values()][0])}));
|
|
""")
|
|
|
|
assert [item["commit_sha"] for item in output["before"]] == ["merge456", "merge789"]
|
|
assert [item["commit_sha"] for item in output["after"]] == ["merge789"]
|
|
assert output["stored"]["version"] == 2
|
|
assert len(output["stored"]["entries"]) == 1
|
|
|
|
|
|
def test_watchlist_refreshes_every_exact_commit_in_one_single_flight():
|
|
output = run_node(r"""
|
|
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=[];
|
|
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async path=>{
|
|
calls.push(path); await new Promise(resolve=>setTimeout(resolve, 5));
|
|
const sha=path.split('/').pop();
|
|
return sha === 'merge456'
|
|
? {commit_sha:sha,ci_state:'failure',checks:[{name:'browser',state:'failure'}],release:null}
|
|
: {commit_sha:sha,ci_state:'success',checks:[],release:{tag:'rc-9',url:'https://forge.example/rc-9'}};
|
|
}});
|
|
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456'});
|
|
receipt.capture({repository:'stackchain/web',number:8,key:'stackchain/web#8'}, {merge_commit_sha:'merge789'});
|
|
(async()=>{
|
|
const [first,second]=await Promise.all([receipt.refresh(),receipt.refresh()]);
|
|
process.stdout.write(JSON.stringify({first,second,calls,restored:receipt.restore()}));
|
|
})();
|
|
""")
|
|
|
|
assert output["calls"] == [
|
|
"api/v1/repos/stackchain/api/release-receipt/merge456",
|
|
"api/v1/repos/stackchain/web/release-receipt/merge789",
|
|
]
|
|
assert [status["label"] for status in output["first"]] == ["Checks failed", "Released · rc-9"]
|
|
assert output["second"] == output["first"]
|
|
assert [item["status"]["commit_sha"] for item in output["restored"]] == ["merge456", "merge789"]
|
|
|
|
|
|
def test_watchlist_renders_failed_merges_first_and_dismisses_individually():
|
|
output = run_node(r"""
|
|
function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(k,fn){this[k]=fn}}; }
|
|
global.document={createElement:tag=>node(tag)};
|
|
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 launcher=node('button'), listNode=node();
|
|
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',launcher,listNode,fetchJson:async path=>{
|
|
const sha=path.split('/').pop();
|
|
return sha === 'failed' ? {commit_sha:sha,ci_state:'failure',checks:[{name:'browser',state:'failure'}]}
|
|
: {commit_sha:sha,ci_state:'pending',checks:[{name:'unit',state:'pending'}]};
|
|
}});
|
|
receipt.capture({repository:'stackchain/api',number:1,key:'stackchain/api#1'}, {merge_commit_sha:'pending'});
|
|
receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {merge_commit_sha:'failed'});
|
|
(async()=>{
|
|
await receipt.refresh();
|
|
const before=listNode.children.map(row=>({key:row.children[0].children[0].textContent,state:row.children[0].children[1].textContent,label:row.children[1]['aria-label']}));
|
|
const beforeLauncher=launcher.textContent;
|
|
listNode.children[0].children[1].click();
|
|
process.stdout.write(JSON.stringify({before,launcher:beforeLauncher,after:receipt.restore()}));
|
|
})();
|
|
""")
|
|
|
|
assert output["before"] == [
|
|
{"key": "stackchain/web#2", "state": "Checks failed", "label": "Dismiss stackchain/web#2 from release tracking"},
|
|
{"key": "stackchain/api#1", "state": "Checks running", "label": "Dismiss stackchain/api#1 from release tracking"},
|
|
]
|
|
assert output["launcher"] == "1 release failure · 2 tracked"
|
|
assert [item["commit_sha"] for item in output["after"]] == ["pending"]
|
|
|
|
|
|
def test_watchlist_renders_confirmed_touch_cleanup_for_the_exact_source_branch():
|
|
output = run_node(r"""
|
|
function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',disabled:false,append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(k,fn){this[k]=fn}}; }
|
|
global.document={createElement:tag=>node(tag)};
|
|
const values=new Map(), calls=[], listNode=node();
|
|
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
|
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',listNode,confirmAction:()=>true,fetchJson:async(path,options={})=>{calls.push({path,method:options.method});return {deleted:true};}});
|
|
receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {merge_commit_sha:'merge456',source_branch:'timmy/feature',source_head_sha:'abc1234',source_repository:'stackchain/api'});
|
|
const row=listNode.children[0], copy=row.children[0], actions=row.children[1], cleanup=actions.children[0];
|
|
(async()=>{ await cleanup.click(); process.stdout.write(JSON.stringify({branch:copy.children[2].textContent,cleanupText:cleanup.textContent,minLabel:cleanup['aria-label'],calls,after:receipt.restore()[0].cleanup})); })();
|
|
""")
|
|
|
|
assert output["branch"] == "Branch timmy/feature · abc1234"
|
|
assert output["cleanupText"] == "Delete source branch"
|
|
assert output["minLabel"] == "Delete merged source branch timmy/feature at abc1234"
|
|
assert output["calls"][0]["method"] == "DELETE"
|
|
assert output["after"]["state"] == "deleted"
|
|
|
|
|
|
def test_watchlist_polls_pending_merges_only_while_foregrounded():
|
|
output = run_node(r"""
|
|
const values=new Map(), scheduled=[], calls=[];
|
|
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
|
const documentRef={hidden:false,addEventListener(type,fn){this[type]=fn}};
|
|
const windowRef={addEventListener(type,fn){this[type]=fn}};
|
|
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',documentRef,windowRef,pollMs:25000,
|
|
setTimer:(fn,ms)=>{scheduled.push({fn,ms});return scheduled.length},clearTimer:()=>{},
|
|
fetchJson:async path=>{calls.push(path);const sha=path.split('/').pop();return {commit_sha:sha,ci_state:'success',checks:[],release:{tag:'rc-'+sha,url:'https://forge.example/'+sha}};}});
|
|
receipt.bind();
|
|
receipt.capture({repository:'stackchain/api',number:1,key:'stackchain/api#1'}, {merge_commit_sha:'one'});
|
|
receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {merge_commit_sha:'two'});
|
|
(async()=>{
|
|
const first=scheduled.at(-1); await first.fn();
|
|
documentRef.hidden=true; documentRef.visibilitychange();
|
|
process.stdout.write(JSON.stringify({delay:first.ms,calls,scheduled:scheduled.length,restored:receipt.restore()}));
|
|
})();
|
|
""")
|
|
|
|
assert output["delay"] == 25000
|
|
assert output["calls"] == [
|
|
"api/v1/repos/stackchain/api/release-receipt/one",
|
|
"api/v1/repos/stackchain/web/release-receipt/two",
|
|
]
|
|
assert output["scheduled"] == 2
|
|
assert all(item["status"]["release"] for item in output["restored"])
|
|
|
|
|
|
def test_watchlist_keeps_refreshing_other_merges_when_one_status_request_fails():
|
|
output = run_node(r"""
|
|
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 receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',fetchJson:async path=>{
|
|
const sha=path.split('/').pop();
|
|
if (sha === 'offline') throw new Error('Network unavailable');
|
|
return {commit_sha:sha,ci_state:'success',checks:[],release:{tag:'rc-ok',url:'https://forge.example/rc-ok'}};
|
|
}});
|
|
receipt.capture({repository:'stackchain/api',number:1,key:'stackchain/api#1'}, {merge_commit_sha:'offline'});
|
|
receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {merge_commit_sha:'released'});
|
|
(async()=>{ const statuses=await receipt.refresh(); process.stdout.write(JSON.stringify({statuses,entries:receipt.restore()})); })();
|
|
""")
|
|
|
|
assert output["statuses"][0]["label"] == "Status unavailable"
|
|
assert output["statuses"][1]["label"] == "Released · rc-ok"
|
|
assert output["entries"][0]["status"]["error"] == "Network unavailable"
|
|
assert output["entries"][1]["status"]["release"]["url"] == "https://forge.example/rc-ok"
|
|
|
|
|
|
def test_watchlist_exposes_release_link_on_the_matching_merge_row():
|
|
output = run_node(r"""
|
|
function node(tag='div') { return {tag,children:[],hidden:false,textContent:'',append(...xs){this.children.push(...xs)},replaceChildren(...xs){this.children=[...xs]},setAttribute(k,v){this[k]=v},addEventListener(){}}; }
|
|
global.document={createElement:tag=>node(tag)};
|
|
const values=new Map(), listNode=node();
|
|
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
|
const receipt=createReleaseReceipt({storage,getLogin:()=> 'timmy',listNode,fetchJson:async()=>({commit_sha:'released',ci_state:'success',checks:[],release:{tag:'rc-12',url:'https://forge.example/rc-12'}})});
|
|
receipt.capture({repository:'stackchain/web',number:2,key:'stackchain/web#2'}, {merge_commit_sha:'released'});
|
|
(async()=>{ await receipt.refresh(); const link=listNode.children[0].children[0].children[2]; process.stdout.write(JSON.stringify({tag:link.tag,href:link.href,text:link.textContent})); })();
|
|
""")
|
|
|
|
assert output == {"tag": "a", "href": "https://forge.example/rc-12", "text": "Open release rc-12"}
|
|
|
|
|
|
def test_mobile_release_receipt_is_wired_into_the_merge_flow_and_phone_safe():
|
|
root = Path(__file__).parents[1]
|
|
html = (root / "frontend" / "index.html").read_text()
|
|
css = (root / "frontend" / "dashboard.css").read_text()
|
|
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
|
|
|
assert '<script src="static/release-receipt.js"></script>' in html
|
|
assert 'id="release-receipt-launcher"' in html
|
|
assert 'id="release-receipt-sheet"' in html
|
|
assert 'id="release-watchlist"' in html
|
|
assert 'rR.capture(merging, mergeResult)' in dashboard
|
|
assert "listNode:qs('#release-watchlist')" in dashboard
|
|
assert "openPull:openPullSheet" in dashboard
|
|
assert "min-height:44px" in css[css.index(".release-receipt-sheet"):]
|
|
assert "overflow-x:hidden" in css[css.index(".release-receipt-sheet"):]
|
|
assert ".release-watchlist-item" in css
|
|
assert ".release-watchlist-actions" in css
|
|
cleanup_css = css[css.index(".release-watchlist-actions"):]
|
|
assert "min-height:44px" in cleanup_css
|
|
assert "grid-template-columns:1fr" in cleanup_css
|
|
recovery_css = css[css.index(".release-failure-summary"):]
|
|
assert "overflow-x:hidden" in recovery_css
|
|
assert "white-space:pre-wrap" in recovery_css
|
|
assert "min-height:44px" in recovery_css
|
|
assert ".release-failure-actions" in recovery_css
|
|
assert "grid-template-columns:1fr" in recovery_css
|