Pause Today during mobile Security Center #1087
|
|
@ -1853,6 +1853,7 @@
|
|||
},
|
||||
onCapture:() => openCreateIssueSheet(),
|
||||
});
|
||||
window.stackchainTodayTimerView = timerView;
|
||||
mobileInsights.start();
|
||||
todaySessionSync = attachTodaySessionHandoff({
|
||||
fetchJson:fetchReviewJson, storage:localStorage, timer, qs,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@
|
|||
<div><h2>Security Center</h2><p class="small muted">Investigate activity, remove untrusted access, and verify the result.</p></div>
|
||||
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
|
||||
</div>
|
||||
<aside class="today-detour-interruption" id="security-center-today-detour" data-today-detour role="status" aria-live="polite" hidden>
|
||||
<strong data-today-detour-label>Today paused</strong>
|
||||
<button id="return-from-security-center" data-return-from-detour type="button">Return to Today</button>
|
||||
</aside>
|
||||
<nav class="security-section-nav" aria-label="Security Center sections">
|
||||
<button id="security-section-activity" type="button">Activity</button>
|
||||
<button id="security-section-devices" type="button">Devices</button>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.attachSecurityCenter = boundary => factory({ root, boundary });
|
||||
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary }) {
|
||||
else root.attachSecurityCenter = (boundary, options = {}) => factory({ root, boundary, ...options });
|
||||
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary, onOpen = () => {}, onClose = () => {} }) {
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
|
|
@ -52,6 +52,7 @@
|
|||
if (!devicesSheet.hidden) {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -222,6 +223,7 @@
|
|||
|
||||
const open = () => {
|
||||
if (!devicesSheet) return;
|
||||
onOpen();
|
||||
devicesSheet.hidden = false;
|
||||
root.document.getElementById('close-active-devices')?.focus();
|
||||
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@
|
|||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const closeDevices = root.document.getElementById('close-active-devices');
|
||||
const returnFromSecurity = root.document.getElementById('return-from-security-center');
|
||||
const beginSecurityDetour = () => {
|
||||
if (root.matchMedia?.('(max-width: 600px)')?.matches) {
|
||||
root.stackchainTodayTimerView?.beginDetour?.('security-center');
|
||||
}
|
||||
};
|
||||
const securityFeatures = root.createFeatureLoader({
|
||||
document: root.document,
|
||||
urls: {
|
||||
|
|
@ -57,6 +63,7 @@
|
|||
const openSecurityCenter = async () => {
|
||||
if (loadingSecurityCenter) return;
|
||||
loadingSecurityCenter = true;
|
||||
beginSecurityDetour();
|
||||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
try {
|
||||
|
|
@ -66,7 +73,10 @@
|
|||
retryLabel: 'Tap Active devices to retry.',
|
||||
}, () => {
|
||||
devicesButton.removeEventListener('click', openSecurityCenter);
|
||||
root.attachSecurityCenter(boundary).open();
|
||||
root.attachSecurityCenter(boundary, {
|
||||
onOpen: beginSecurityDetour,
|
||||
onClose: () => root.stackchainTodayTimerView?.finishDetour?.('security-center'),
|
||||
}).open();
|
||||
});
|
||||
} finally {
|
||||
loadingSecurityCenter = false;
|
||||
|
|
@ -78,6 +88,11 @@
|
|||
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
root.stackchainTodayTimerView?.finishDetour?.('security-center');
|
||||
});
|
||||
if (returnFromSecurity && devicesSheet) returnFromSecurity.addEventListener('click', () => {
|
||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||
else devicesSheet.hidden = true;
|
||||
});
|
||||
boundary.refreshOfflineLease().then(valid => {
|
||||
if (valid) boundary.resumeQueuedWork();
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
|||
const validDetour = state => {
|
||||
const pending = state.detour_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup'].includes(pending.reason) ?
|
||||
typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup', 'security-center'].includes(pending.reason) ?
|
||||
{ identity:pending.identity, resume:pending.resume, reason:pending.reason } : null;
|
||||
};
|
||||
const validBreak = state => {
|
||||
|
|
@ -289,7 +289,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
|||
return write(state) ? { identity:pending.identity, resumed } : null;
|
||||
},
|
||||
beginDetour(reason) {
|
||||
if (!['find', 'queues', 'insights', 'device-setup'].includes(reason)) return null;
|
||||
if (!['find', 'queues', 'insights', 'device-setup', 'security-center'].includes(reason)) return null;
|
||||
const state = read();
|
||||
const existing = validDetour(state);
|
||||
if (existing) return existing;
|
||||
|
|
@ -304,9 +304,10 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
|||
detourInterruption() {
|
||||
return validDetour(read());
|
||||
},
|
||||
returnFromDetour() {
|
||||
returnFromDetour(reason = '') {
|
||||
const state = read();
|
||||
const pending = validDetour(state);
|
||||
if (reason && pending?.reason !== reason) return null;
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!pending || !entry || state.active_identity !== pending.identity) return null;
|
||||
const resumed = pending.resume && !entry.running;
|
||||
|
|
@ -521,7 +522,7 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
|||
reopen(identity) { onReopen?.(identity); },
|
||||
search(action, ...args) { return searchView[action]?.(...args); },
|
||||
beginDetour(reason) { return detourView.open(reason); },
|
||||
finishDetour() { return detourView.finish(); },
|
||||
finishDetour(reason) { return detourView.finish(reason); },
|
||||
finish() { timer.stop(); progress = null; runway = null; render(); },
|
||||
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
|
||||
reset() { progress = null; runway = null; render(); },
|
||||
|
|
@ -701,9 +702,9 @@ function createTodayDetourInterruption({ timer, queryAll, getItemLabel, onChange
|
|||
return pending;
|
||||
},
|
||||
restore() { return render(); },
|
||||
finish() {
|
||||
const result = timer.returnFromDetour?.();
|
||||
render(null);
|
||||
finish(reason) {
|
||||
const result = timer.returnFromDetour?.(reason);
|
||||
if (result || !reason) render(null);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user