feat: prepare conflict-safe release rollback pulls (Closes #1370)
This commit is contained in:
1 parent
400534f446
commit
b753cdd922
25 files changed
+754
-43
No files matched your search
@@ -89,3 +89,47 @@ def test_failed_release_check_is_diagnosable_and_retryable_on_phone(viewport):
|
||||
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()
|
||||
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v138" in worker
|
||||
assert "stackchain-dashboard-shell-v139" in worker
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import get_args
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
@@ -13,6 +14,10 @@ from src.session_store import SessionStoreError
|
||||
from src.views import FRONTEND_BUILD
|
||||
|
||||
|
||||
def test_release_rollback_is_a_supported_step_up_action():
|
||||
assert "prepare_release_rollback" in get_args(main.StepUpAction)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def access_control(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
|
||||
@@ -593,7 +593,7 @@ process.stdout.write(JSON.stringify({{
|
||||
assert ".following-disposition-mode" in css
|
||||
assert "if (searchPreviewReturnKind === 'following')" in dashboard
|
||||
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
|
||||
assert "stackchain-dashboard-shell-v138" in service_worker
|
||||
assert "stackchain-dashboard-shell-v139" in service_worker
|
||||
|
||||
|
||||
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
||||
|
||||
@@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v138" in worker
|
||||
assert "stackchain-dashboard-shell-v139" in worker
|
||||
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v138" in worker
|
||||
assert "stackchain-dashboard-shell-v139" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "controller.recoverPermission('deadline')" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v138" in worker
|
||||
assert "stackchain-dashboard-shell-v139" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -274,5 +274,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in worker
|
||||
assert "stackchain-dashboard-shell-v139" in worker
|
||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||
@@ -414,7 +414,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||
assert "stackchain-dashboard-shell-v138" in service_worker
|
||||
assert "stackchain-dashboard-shell-v139" in service_worker
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
|
||||
import httpx
|
||||
@@ -8,6 +9,15 @@ from src import gitea_proxy, main
|
||||
from src.security_event_store import SecurityEventStoreError
|
||||
|
||||
|
||||
def _content_payload(text: str, sha: str) -> dict:
|
||||
return {
|
||||
"type": "file",
|
||||
"encoding": "base64",
|
||||
"content": base64.b64encode(text.encode()).decode(),
|
||||
"sha": sha,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_updates_authored_pull_branch_and_confirms_new_head():
|
||||
requests = []
|
||||
@@ -2320,3 +2330,222 @@ async def test_source_branch_cleanup_resolves_a_lost_delete_response_with_one_ab
|
||||
|
||||
assert result["deleted"] is True
|
||||
assert sum(method == "GET" and path.endswith("/branches/timmy%2Ffeature") for method, path in requests) == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_prepare_release_rollback_creates_one_atomic_draft_pull_from_exact_merge_parent():
|
||||
requests = []
|
||||
branch_created = False
|
||||
merge_sha = "abc1234def5678"
|
||||
parent_sha = "1111111aaaaaaa"
|
||||
current_sha = "2222222bbbbbbb"
|
||||
rollback_sha = "3333333ccccccc"
|
||||
branch = "timmy/rollback-7-abc1234def56"
|
||||
|
||||
async def handler(request):
|
||||
nonlocal branch_created
|
||||
path = request.url.raw_path.decode().split("?", 1)[0]
|
||||
requests.append((request.method, path))
|
||||
if path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if path.endswith("/pulls/7") and request.method == "GET":
|
||||
return httpx.Response(200, json={
|
||||
"number": 7,
|
||||
"state": "closed",
|
||||
"merged": True,
|
||||
"merge_commit_sha": merge_sha,
|
||||
"title": "Ship release",
|
||||
"user": {"login": "timmy"},
|
||||
"assignees": [],
|
||||
})
|
||||
if path.endswith("/repos/stackchain/api"):
|
||||
return httpx.Response(200, json={
|
||||
"default_branch": "main", "permissions": {"push": True},
|
||||
})
|
||||
if path.endswith(f"/git/commits/{merge_sha}"):
|
||||
return httpx.Response(200, json={
|
||||
"sha": merge_sha,
|
||||
"parents": [{"sha": parent_sha}, {"sha": "feature-parent"}],
|
||||
"files": [{"filename": "app.txt", "status": "modified"}],
|
||||
})
|
||||
if path.endswith("/contents/app.txt"):
|
||||
ref = request.url.params.get("ref")
|
||||
if ref == merge_sha:
|
||||
return httpx.Response(200, json=_content_payload("broken\n", "merge-blob"))
|
||||
if ref == parent_sha:
|
||||
return httpx.Response(200, json=_content_payload("working\n", "parent-blob"))
|
||||
if ref == "main":
|
||||
return httpx.Response(200, json=_content_payload("broken\n", "current-blob"))
|
||||
if path.endswith("/branches/timmy%2Frollback-7-abc1234def56"):
|
||||
if request.method == "GET":
|
||||
return httpx.Response(
|
||||
200, json={"commit": {"id": rollback_sha}}
|
||||
) if branch_created else httpx.Response(404)
|
||||
if path.endswith("/contents") and request.method == "POST":
|
||||
body = json.loads(request.content)
|
||||
assert body == {
|
||||
"branch": "main",
|
||||
"new_branch": branch,
|
||||
"message": f"Revert {merge_sha} from pull #7",
|
||||
"files": [{
|
||||
"operation": "update",
|
||||
"path": "app.txt",
|
||||
"sha": "current-blob",
|
||||
"content": base64.b64encode(b"working\n").decode(),
|
||||
}],
|
||||
}
|
||||
branch_created = True
|
||||
return httpx.Response(201, json={"commit": {"sha": rollback_sha}})
|
||||
if path.endswith("/pulls") and request.method == "GET":
|
||||
return httpx.Response(200, json=[])
|
||||
if path.endswith("/pulls") and request.method == "POST":
|
||||
body = json.loads(request.content)
|
||||
assert body["head"] == branch
|
||||
assert body["base"] == "main"
|
||||
assert body["draft"] is True
|
||||
assert f"Rollback of #{7} at `{merge_sha}`" in body["body"]
|
||||
return httpx.Response(201, json={
|
||||
"number": 8,
|
||||
"title": body["title"],
|
||||
"body": body["body"],
|
||||
"state": "open",
|
||||
"draft": True,
|
||||
"head": {"ref": branch, "sha": rollback_sha},
|
||||
"base": {"ref": "main"},
|
||||
"user": {"login": "timmy"},
|
||||
"html_url": "https://forge.example/git/stackchain/api/pulls/8",
|
||||
})
|
||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.prepare_release_rollback(
|
||||
"stackchain/api", 7, merge_sha
|
||||
)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result["number"] == 8
|
||||
assert result["draft"] is True
|
||||
assert result["head"] == {"ref": branch, "sha": rollback_sha}
|
||||
assert result["rollback_of"] == merge_sha
|
||||
assert result["files_changed"] == 1
|
||||
assert sum(method == "POST" and path.endswith("/contents") for method, path in requests) == 1
|
||||
assert sum(method == "POST" and path.endswith("/pulls") for method, path in requests) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_release_rollback_endpoint_requires_failed_exact_commit_and_audits_draft_pull(monkeypatch):
|
||||
lifecycle = []
|
||||
|
||||
class Journal:
|
||||
def reserve(self, kind, *, target):
|
||||
lifecycle.append(("reserve", kind, target))
|
||||
return "rollback-operation"
|
||||
|
||||
def finalize(self, operation_id):
|
||||
lifecycle.append(("finalize", operation_id))
|
||||
|
||||
def discard(self, operation_id):
|
||||
lifecycle.append(("discard", operation_id))
|
||||
|
||||
async def status(repository, commit_sha):
|
||||
lifecycle.append(("status", repository, commit_sha))
|
||||
return {"commit_sha": commit_sha, "ci_state": "failure", "release": None}
|
||||
|
||||
async def prepare(repository, number, commit_sha):
|
||||
lifecycle.append(("prepare", repository, number, commit_sha))
|
||||
return {
|
||||
"repository": repository,
|
||||
"number": 9,
|
||||
"state": "open",
|
||||
"draft": True,
|
||||
"head": {"ref": "timmy/rollback-7-abc1234", "sha": "def5678"},
|
||||
"base": {"ref": "main"},
|
||||
"url": "https://forge.example/git/stackchain/api/pulls/9",
|
||||
"rollback_of": commit_sha,
|
||||
"files_changed": 2,
|
||||
"existing": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main, "_security_event_store", lambda: Journal())
|
||||
monkeypatch.setattr(main.gitea_proxy, "release_receipt_status", status)
|
||||
monkeypatch.setattr(main.gitea_proxy, "prepare_release_rollback", prepare, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/pulls/7/release-receipt/abc1234/rollback"
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json()["rollback_of"] == "abc1234"
|
||||
assert lifecycle == [
|
||||
("status", "stackchain/api", "abc1234"),
|
||||
("reserve", "release_rollback_prepared", "stackchain/api#7@abc1234"),
|
||||
("prepare", "stackchain/api", 7, "abc1234"),
|
||||
("finalize", "rollback-operation"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_prepare_release_rollback_reconciles_a_concurrent_branch_creation_without_duplicate_commit():
|
||||
merge_sha = "abc1234def5678"
|
||||
rollback_sha = "3333333ccccccc"
|
||||
branch_reads = 0
|
||||
pull_created = False
|
||||
|
||||
async def handler(request):
|
||||
nonlocal branch_reads, pull_created
|
||||
path = request.url.raw_path.decode().split("?", 1)[0]
|
||||
if path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if path.endswith("/pulls/7"):
|
||||
return httpx.Response(200, json={
|
||||
"state": "closed", "merged": True, "merge_commit_sha": merge_sha,
|
||||
"title": "Ship release", "user": {"login": "timmy"}, "assignees": [],
|
||||
})
|
||||
if path.endswith("/repos/stackchain/api"):
|
||||
return httpx.Response(200, json={"default_branch": "main", "permissions": {"push": True}})
|
||||
if path.endswith(f"/git/commits/{merge_sha}"):
|
||||
return httpx.Response(200, json={
|
||||
"sha": merge_sha, "parents": [{"sha": "parent123"}, {"sha": "feature123"}],
|
||||
"files": [{"filename": "app.txt", "status": "modified"}],
|
||||
})
|
||||
if path.endswith("/contents/app.txt"):
|
||||
ref = request.url.params["ref"]
|
||||
return httpx.Response(200, json=_content_payload(
|
||||
"working\n" if ref == "parent123" else "broken\n",
|
||||
"current-blob" if ref == "main" else ref + "-blob",
|
||||
))
|
||||
if path.endswith("/branches/timmy%2Frollback-7-abc1234def56"):
|
||||
branch_reads += 1
|
||||
return httpx.Response(404) if branch_reads == 1 else httpx.Response(
|
||||
200, json={"commit": {"id": rollback_sha}}
|
||||
)
|
||||
if path.endswith("/contents") and request.method == "POST":
|
||||
return httpx.Response(422, json={"message": "branch already exists"})
|
||||
if path.endswith("/pulls") and request.method == "GET":
|
||||
return httpx.Response(200, json=[])
|
||||
if path.endswith("/pulls") and request.method == "POST":
|
||||
pull_created = True
|
||||
body = json.loads(request.content)
|
||||
return httpx.Response(201, json={
|
||||
"number": 9, "title": body["title"], "body": body["body"],
|
||||
"state": "open", "draft": True,
|
||||
"head": {"ref": body["head"], "sha": rollback_sha},
|
||||
"base": {"ref": body["base"]}, "user": {"login": "timmy"},
|
||||
"html_url": "https://forge.example/git/stackchain/api/pulls/9",
|
||||
})
|
||||
raise AssertionError(f"unexpected request {request.method} {request.url}")
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await gitea_proxy.prepare_release_rollback("stackchain/api", 7, merge_sha)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result["number"] == 9
|
||||
assert result["head"]["sha"] == rollback_sha
|
||||
assert branch_reads == 3 # initial check, race reconciliation, pull-creation revalidation
|
||||
assert pull_created is True
|
||||
@@ -174,6 +174,54 @@ receipt.capture({repository:'stackchain/api',number:7,key:'stackchain/api#7'}, {
|
||||
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();
|
||||
@@ -345,8 +393,9 @@ def test_mobile_release_receipt_is_wired_into_the_merge_flow_and_phone_safe():
|
||||
assert 'id="release-receipt-launcher"' in html
|
||||
assert 'id="release-receipt-sheet"' in html
|
||||
assert 'id="release-watchlist"' in html
|
||||
assert 'releaseReceipt.capture(merging, mergeResult)' in dashboard
|
||||
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
|
||||
|
||||
@@ -12,6 +12,12 @@ def test_source_branch_cleanup_has_a_specific_security_activity_label():
|
||||
assert "source_branch_deleted: 'Source branch deleted'" in source
|
||||
|
||||
|
||||
def test_release_rollback_has_a_specific_security_activity_label():
|
||||
source = SECURITY_CENTER.read_text()
|
||||
|
||||
assert "release_rollback_prepared: 'Release rollback prepared'" in source
|
||||
|
||||
|
||||
def test_open_security_center_loads_all_sections_concurrently_and_is_awaitable():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
|
||||
@@ -189,7 +189,7 @@ async function dispatchPush(payload) {{
|
||||
def test_week_unplan_undo_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
|
||||
@@ -197,20 +197,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
|
||||
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
|
||||
|
||||
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/week-plan.js'" in source
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -219,7 +219,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/authored-outbox.js'" in source
|
||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||
@@ -228,7 +228,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||
assert "BASE + 'static/issue-attachment.js'" in source
|
||||
|
||||
@@ -236,14 +236,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -251,7 +251,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -259,7 +259,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/issue-sheet.js'" in source
|
||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
@@ -269,14 +269,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -285,21 +285,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -1354,7 +1354,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v138';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v139';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v138" in source
|
||||
assert "stackchain-dashboard-shell-v139" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user