feat: pause Today during mobile Security Center (Closes #1086)
CI / lint (pull_request) Successful in 3m27s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Successful in 2m59s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-18 16:41:59 +00:00
1 parent aba4b55ca0
commit 473f592615
11 files changed
+174 -14

No files matched your search

@@ -446,6 +446,37 @@ def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(t
const timer = JSON.parse(localStorage.getItem(key));
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
}""")
page.locator("#close-issue-sheet").click()
page.locator("#app-menu-toggle").click()
page.locator("#active-devices").click()
security_pause = page.locator("#security-center-today-detour")
expect(security_pause).to_be_visible()
expect(security_pause).to_contain_text("Today paused · Ship mobile capture")
bounds = page.locator("#return-from-security-center").bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert page.evaluate("""() => {
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
const timer = JSON.parse(localStorage.getItem(key));
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'security-center';
}""")
page.locator("#security-section-devices").click()
page.go_back()
expect(security_pause).to_be_visible()
page.go_back()
expect(page.locator("#active-devices-sheet")).to_be_hidden()
assert page.evaluate("""() => {
const key = Object.keys(localStorage).find(value => value.startsWith('stackchain.today-timer.v1.'));
const timer = JSON.parse(localStorage.getItem(key));
return timer.entries[timer.active_identity].running === true && !timer.detour_interruption;
}""")
page.locator("#active-devices").click()
expect(security_pause).to_be_visible()
page.locator("#return-from-security-center").click()
expect(page.locator("#active-devices-sheet")).to_be_hidden()
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
browser.close()
finally:
fake.shutdown()
+13
View File
@@ -12,6 +12,19 @@ SESSION_JS = ROOT / "frontend" / "session.js"
SECURITY_CENTER_JS = ROOT / "frontend" / "security-center.js"
@pytest.mark.anyio
async def test_security_center_wires_mobile_today_pause_before_lazy_loading_and_visible_return():
html = await dashboard()
session = SESSION_JS.read_text()
dashboard_js = (ROOT / "frontend" / "dashboard.js").read_text()
assert 'id="security-center-today-detour" data-today-detour' in html
assert 'id="return-from-security-center" data-return-from-detour type="button">Return to Today</button>' in html
assert session.index("beginDetour?.('security-center')") < session.index("devicesSheet.hidden = false;")
assert "onClose: () => root.stackchainTodayTimerView?.finishDetour?.('security-center')" in session
assert "window.stackchainTodayTimerView = timerView;" in dashboard_js
def run_session_scenario(scenario: str) -> dict:
harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
+3 -2
View File
@@ -78,8 +78,9 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createDetailDefer" in first.feature_bundles["today-timer"].runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes
# Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions.
assert len(first.runtime_gzip_bytes) <= 99 * 1024
# Core mobile workflows stay below 100 KiB, including synced Search, Update decisions,
# and interruption-safe Security Center navigation.
assert len(first.runtime_gzip_bytes) <= 100 * 1024
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
+2 -2
View File
@@ -254,8 +254,8 @@ process.stdout.write(JSON.stringify({{calls, open:queues.open}}));
async def test_find_and_queue_detours_are_visible_and_wired_to_today_timing():
html = await dashboard()
assert html.count('data-today-detour role="status"') == 5
assert html.count('data-return-from-detour type="button"') == 5
assert html.count('data-today-detour role="status"') == 6
assert html.count('data-return-from-detour type="button"') == 6
assert "detour:() => timerView" in html
timer_source = TIMER.read_text()
assert "createTodayDetourInterruption" in timer_source
+43
View File
@@ -230,6 +230,49 @@ const boundary={{
assert output["pushed"] == ["/dashboard/#security/activity", "/dashboard/#security/devices"]
def test_security_center_finishes_its_today_detour_only_when_the_sheet_closes():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
const listeners={{}};
const element=()=>({{
hidden:true,textContent:'',children:[],attributes:{{}},
addEventListener(){{}},focus(){{}},replaceChildren(){{this.children=[];}},append(...values){{this.children.push(...values);}},
setAttribute(name,value){{this.attributes[name]=value;}},removeAttribute(name){{delete this.attributes[name];}},scrollIntoView(){{}},
}});
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
'security-activity-status','load-more-security-activity','close-active-devices',
'security-section-activity','security-section-devices','security-section-passkeys',
'security-activity-section','security-devices-section','security-passkeys-section'];
const ids=Object.fromEntries(names.map(id=>[id,element()]));
const root={{
document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}},
history:{{state:null,pushState(state){{this.state=state;}}}},
location:{{pathname:'/dashboard/',search:''}},
addEventListener:(type,listener)=>listeners[type]=listener,
}};
const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}})}};
let closes=0;
(async()=>{{
const controller=attachSecurityCenter({{root,boundary,onClose:()=>closes++}});
await controller.open();
listeners.popstate({{state:{{stackchainSecuritySection:'devices'}}}});
const afterSectionBack=closes;
listeners.popstate({{state:null}});
listeners.popstate({{state:null}});
console.log(JSON.stringify({{afterSectionBack,closes,hidden:ids['active-devices-sheet'].hidden}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {
"afterSectionBack": 0,
"closes": 1,
"hidden": True,
}
def test_close_uses_history_back_to_remove_security_center_route():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
+49
View File
@@ -313,6 +313,26 @@ process.stdout.write(JSON.stringify({pausedDetour, runningDetour, replacedReturn
}
def test_security_center_close_cannot_finish_an_unrelated_detour():
script = SOURCE.read_text() + r"""
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>1000});
timer.activate('issue:r:1:');
timer.beginDetour('insights');
const returned = timer.returnFromDetour('security-center');
process.stdout.write(JSON.stringify({returned, pending:timer.detourInterruption(), snapshot:timer.snapshot()}));
"""
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {
"returned": None,
"pending": {"identity": "issue:r:1:", "resume": True, "reason": "insights"},
"snapshot": {"identity": "issue:r:1:", "elapsed_ms": 0, "running": False},
}
def test_device_setup_detour_excludes_permission_time_and_survives_reload():
script = SOURCE.read_text() + r"""
const values = new Map();
@@ -342,6 +362,35 @@ process.stdout.write(JSON.stringify({interruption, restored, paused, returned, r
}
def test_security_center_detour_excludes_investigation_time_and_resumes_once():
script = SOURCE.read_text() + r"""
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
let now = 1000;
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
timer.activate('issue:r:42:');
now = 6000;
const interruption = timer.beginDetour('security-center');
now = 26000;
const paused = timer.snapshot();
const returned = timer.returnFromDetour();
now = 28000;
const resumed = timer.snapshot();
const repeated = timer.returnFromDetour();
process.stdout.write(JSON.stringify({interruption, paused, returned, resumed, repeated}));
"""
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {
"interruption": {"identity": "issue:r:42:", "resume": True, "reason": "security-center"},
"paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
"returned": {"identity": "issue:r:42:", "resumed": True, "reason": "security-center"},
"resumed": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
"repeated": None,
}
def test_mobile_detour_view_names_work_and_returns_to_today():
script = SOURCE.read_text() + r"""
const values = new Map();