import os from pathlib import Path import pytest if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1": pytest.skip("packaged release-failure recovery runs only in the browser gate", allow_module_level=True) pytest.importorskip("playwright.sync_api") from playwright.sync_api import expect, sync_playwright ROOT = Path(__file__).parents[2] FRONTEND = ROOT / "frontend" @pytest.mark.parametrize("viewport", [ {"width": 320, "height": 568}, {"width": 390, "height": 844}, ]) def test_failed_release_check_is_diagnosable_and_retryable_on_phone(viewport): with sync_playwright() as playwright: browser = playwright.chromium.launch(headless=True) page = browser.new_page(viewport=viewport) page.set_content((FRONTEND / "index.html").read_text()) page.add_style_tag(path=FRONTEND / "dashboard.css") page.add_script_tag(path=FRONTEND / "release-receipt.js") result = page.evaluate("""async () => { const calls=[]; 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', listNode:document.querySelector('#release-watchlist'), 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: mobile viewport overflow', }; if (path.endsWith('/retry')) return {commit_sha:'abc1234', status:'queued'}; return { commit_sha:'abc1234', ci_state:'failure', release:null, checks:[{ name:'CI / browser with a deliberately long workflow name', state:'failure', description:'Playwright failed at the narrow viewport', url:'https://forge.example/git/stackchain/api/actions/runs/91/jobs/3', recovery:{run_id:91, job_index:3}, }], }; }, }); receipt.capture( {repository:'stackchain/api-with-a-long-mobile-name', number:7, key:'stackchain/api-with-a-long-mobile-name#7'}, {merge_commit_sha:'abc1234'}, ); document.querySelector('#release-receipt-sheet').showModal(); await receipt.refresh(); const review=document.querySelector('.release-failure-summary button'); const reviewHeight=review.getBoundingClientRect().height; review.click(); await new Promise(resolve=>setTimeout(resolve, 0)); const recovery=document.querySelector('.release-failure-recovery'); const retry=recovery.querySelector('button'); const retryHeight=retry.getBoundingClientRect().height; const excerpt=recovery.querySelector('pre').textContent; const sha=recovery.querySelector('strong').textContent; const job=recovery.querySelector('a').href; const overflow=document.documentElement.scrollWidth > document.documentElement.clientWidth; retry.click(); await new Promise(resolve=>setTimeout(resolve, 0)); return { calls, reviewHeight, retryHeight, excerpt, sha, job, overflow, state:receipt.restore()[0].status.label, }; }""") expect(page.locator("#release-receipt-sheet")).to_be_visible() assert result["reviewHeight"] >= 44 assert result["retryHeight"] >= 44 assert result["excerpt"] == "AssertionError: mobile viewport overflow" assert result["sha"] == "Merge abc1234" assert result["job"].endswith("/actions/runs/91/jobs/3") assert result["overflow"] is False assert result["state"] == "Checks running" assert [call["method"] for call in result["calls"]] == ["GET", "GET", "POST"] browser.close() @pytest.mark.parametrize("viewport", [ {"width": 320, "height": 568}, {"width": 390, "height": 844}, ]) def test_failed_release_prepares_a_visible_draft_rollback_receipt_on_phone(viewport): with sync_playwright() as playwright: browser = playwright.chromium.launch(headless=True) page = browser.new_page(viewport=viewport) page.set_content((FRONTEND / "index.html").read_text()) page.add_style_tag(path=FRONTEND / "dashboard.css") page.add_script_tag(path=FRONTEND / "release-receipt.js") result = page.evaluate("""async () => { const values=new Map(), calls=[], opened=[]; const receipt=createReleaseReceipt({ storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}, getLogin:()=> 'timmy', listNode:document.querySelector('#release-watchlist'), 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:'failed'}; if(path.endsWith('/rollback')) return {repository:'stackchain/api',number:9,draft:true,rollback_of:'abc1234',url:'https://forge.example/git/stackchain/api/pulls/9'}; return {commit_sha:'abc1234',ci_state:'failure',release:null,checks:[{name:'CI / browser',state:'failure',recovery:{run_id:91,job_index:3}}]}; }, }); receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'},{merge_commit_sha:'abc1234'}); document.querySelector('#release-receipt-sheet').showModal(); await receipt.refresh(); document.querySelector('.release-failure-summary button').click(); await new Promise(resolve=>setTimeout(resolve,0)); const rollback=[...document.querySelectorAll('.release-failure-actions button')].find(button=>button.textContent==='Prepare rollback PR'); const actionHeight=rollback.getBoundingClientRect().height; rollback.click(); await new Promise(resolve=>setTimeout(resolve,0)); const status=document.querySelector('.release-rollback-status'); return {calls,opened,actionHeight,status:status?.textContent||'',overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth}; }""") assert result["actionHeight"] >= 44 assert result["status"] == "Draft rollback #9 is ready for review." assert result["opened"][0]["number"] == 9 assert result["calls"][-1]["path"].endswith("/release-receipt/abc1234/rollback") assert result["overflow"] is False browser.close()