feat: hand off active Today sessions across devices (Closes #1022)
This commit is contained in:
1 parent
51f4223f4b
commit
8d12a0384b
14 files changed
+596
-7
No files matched your search
@@ -988,6 +988,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/work-selection.js",
|
||||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/today-timer.js",
|
||||
"/dashboard/static/today-session-sync.js",
|
||||
"/dashboard/static/today-recap.js",
|
||||
"/dashboard/static/today-wrap-up.js",
|
||||
"/dashboard/static/today-handoff.js",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.frontend_bundle import build_frontend
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
|
||||
|
||||
def test_dashboard_packages_a_mobile_today_session_handoff():
|
||||
build = build_frontend(ROOT / "frontend")
|
||||
html = build.dashboard_html
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
today_bundle = build.feature_bundles["today-timer"].runtime_bytes.decode()
|
||||
|
||||
assert 'id="today-session-handoff"' in html
|
||||
assert 'id="continue-today-session"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "createTodaySessionSync" in today_bundle
|
||||
assert "attachTodaySessionHandoff" in today_bundle
|
||||
assert "todaySessionSync = attachTodaySessionHandoff" in dashboard
|
||||
assert "todaySessionSync?.publish(snapshot)" in dashboard
|
||||
assert "min-height:44px" in css
|
||||
@@ -0,0 +1,121 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SOURCE = Path(__file__).parents[1] / "frontend" / "today-session-sync.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
completed = subprocess.run(
|
||||
["node", "-e", SOURCE.read_text() + "\n" + script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_second_device_sees_and_claims_the_running_session():
|
||||
result = run_node(
|
||||
r"""
|
||||
const calls = [];
|
||||
const remote = {revision:3, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:90000, running:true, updated_at:10};
|
||||
let adopted = null;
|
||||
const offers = [];
|
||||
const sync = createTodaySessionSync({
|
||||
getDeviceId:()=>'phone-b',
|
||||
fetchJson:async (url, options={}) => {
|
||||
calls.push({url, body:options.body ? JSON.parse(options.body) : null});
|
||||
return options.method === 'PATCH' ? {...remote, revision:4, device_id:'phone-b'} : remote;
|
||||
},
|
||||
timer:{adopt:(identity, elapsed, running)=>{adopted={identity, elapsed, running};}},
|
||||
onRemote:session=>{offers.push(session);},
|
||||
});
|
||||
(async()=>{
|
||||
await sync.refresh();
|
||||
const claimed = await sync.claim();
|
||||
process.stdout.write(JSON.stringify({offers, claimed, adopted, calls}));
|
||||
})().catch(error=>{console.error(error);process.exit(1);});
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["offers"][0]["device_id"] == "desktop-a"
|
||||
assert result["claimed"]["device_id"] == "phone-b"
|
||||
assert result["adopted"] == {
|
||||
"identity": "issue:r:42:", "elapsed": 90000, "running": True,
|
||||
}
|
||||
assert result["calls"][1] == {
|
||||
"url": "api/v1/today/session",
|
||||
"body": {
|
||||
"base_revision": 3,
|
||||
"device_id": "phone-b",
|
||||
"identity": "issue:r:42:",
|
||||
"elapsed_ms": 90000,
|
||||
"running": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_previous_owner_pauses_after_another_device_claims():
|
||||
result = run_node(
|
||||
r"""
|
||||
let response = {revision:1, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
|
||||
let pauses = 0;
|
||||
let transferred = null;
|
||||
const sync = createTodaySessionSync({
|
||||
getDeviceId:()=> 'desktop-a',
|
||||
fetchJson:async()=>response,
|
||||
timer:{pause:()=>{pauses += 1;}},
|
||||
onTransferred:session=>{transferred=session;},
|
||||
});
|
||||
(async()=>{
|
||||
await sync.refresh();
|
||||
response = {...response, revision:2, device_id:'phone-b', elapsed_ms:2500};
|
||||
await sync.refresh();
|
||||
await sync.refresh();
|
||||
process.stdout.write(JSON.stringify({pauses, transferred}));
|
||||
})().catch(error=>{console.error(error);process.exit(1);});
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["pauses"] == 1
|
||||
assert result["transferred"]["device_id"] == "phone-b"
|
||||
|
||||
|
||||
def test_owner_poll_publishes_current_elapsed_time():
|
||||
result = run_node(
|
||||
r"""
|
||||
const calls = [];
|
||||
let tick = null;
|
||||
let elapsed = 1000;
|
||||
const owned = {revision:1, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
|
||||
const sync = createTodaySessionSync({
|
||||
getDeviceId:()=> 'desktop-a',
|
||||
fetchJson:async (url, options={}) => {
|
||||
calls.push({method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null});
|
||||
return options.method === 'PATCH' ? {...owned, revision:2, elapsed_ms:elapsed} : owned;
|
||||
},
|
||||
timer:{snapshot:()=>({identity:owned.identity, elapsed_ms:elapsed, running:true})},
|
||||
setInterval:callback=>{tick=callback; return 7;},
|
||||
});
|
||||
(async()=>{
|
||||
await sync.refresh();
|
||||
sync.start();
|
||||
elapsed = 6500;
|
||||
await tick();
|
||||
process.stdout.write(JSON.stringify(calls));
|
||||
})().catch(error=>{console.error(error);process.exit(1);});
|
||||
"""
|
||||
)
|
||||
|
||||
assert result[-1] == {
|
||||
"method": "PATCH",
|
||||
"body": {
|
||||
"base_revision": 1,
|
||||
"device_id": "desktop-a",
|
||||
"identity": "issue:r:42:",
|
||||
"elapsed_ms": 6500,
|
||||
"running": True,
|
||||
},
|
||||
}
|
||||
@@ -4,7 +4,80 @@ import pytest
|
||||
import httpx
|
||||
|
||||
from src import main
|
||||
from src.today_store import TodayPlanFull, TodayStore
|
||||
from src.today_store import TodayPlanFull, TodaySessionConflict, TodayStore
|
||||
|
||||
|
||||
def test_active_session_is_durable_and_account_scoped(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, clock=lambda: 1234.5)
|
||||
|
||||
saved = store.update_session(
|
||||
"Timmy", base_revision=0, device_id="phone-a",
|
||||
identity="issue:stackchain/dashboard:42:", elapsed_ms=90_000, running=True,
|
||||
)
|
||||
|
||||
assert saved == {
|
||||
"revision": 1,
|
||||
"device_id": "phone-a",
|
||||
"identity": "issue:stackchain/dashboard:42:",
|
||||
"elapsed_ms": 90_000,
|
||||
"running": True,
|
||||
"updated_at": 1234.5,
|
||||
}
|
||||
assert TodayStore(path).get_session("timmy") == saved
|
||||
assert store.get_session("alexander") == {
|
||||
"revision": 0,
|
||||
"device_id": "",
|
||||
"identity": "",
|
||||
"elapsed_ms": 0,
|
||||
"running": False,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
|
||||
def test_active_session_claim_rejects_a_stale_revision(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3")
|
||||
first = store.update_session(
|
||||
"timmy", base_revision=0, device_id="phone-a", identity="issue:r:1:",
|
||||
elapsed_ms=1_000, running=True,
|
||||
)
|
||||
claimed = store.update_session(
|
||||
"timmy", base_revision=first["revision"], device_id="phone-b",
|
||||
identity=first["identity"], elapsed_ms=first["elapsed_ms"], running=True,
|
||||
)
|
||||
|
||||
with pytest.raises(TodaySessionConflict) as raised:
|
||||
store.update_session(
|
||||
"timmy", base_revision=first["revision"], device_id="phone-a",
|
||||
identity=first["identity"], elapsed_ms=5_000, running=True,
|
||||
)
|
||||
|
||||
assert raised.value.session == claimed
|
||||
assert store.get_session("timmy") == claimed
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_active_session_api_returns_current_state_on_claim_conflict(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
store = TodayStore(tmp_path / "today.sqlite3")
|
||||
current = store.update_session(
|
||||
"timmy", base_revision=0, device_id="phone-a", identity="issue:r:1:",
|
||||
elapsed_ms=5_000, running=True,
|
||||
)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
|
||||
payload = main.TodaySessionUpdate(
|
||||
base_revision=0, device_id="phone-b", identity="issue:r:1:",
|
||||
elapsed_ms=5_000, running=True,
|
||||
)
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.update_today_session(payload)
|
||||
|
||||
assert raised.value.status_code == 409
|
||||
assert raised.value.detail == {"code": "session_changed", "session": current}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -342,6 +415,21 @@ async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypat
|
||||
},
|
||||
)
|
||||
fetched = await client.get("/api/v1/today")
|
||||
changed_session = await client.patch(
|
||||
"/api/v1/today/session",
|
||||
json={
|
||||
"base_revision": 0,
|
||||
"device_id": "phone-a",
|
||||
"identity": "issue:stackchain/dashboard:357:",
|
||||
"elapsed_ms": 12_000,
|
||||
"running": True,
|
||||
},
|
||||
headers={
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
},
|
||||
)
|
||||
fetched_session = await client.get("/api/v1/today/session")
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert changed.status_code == 200
|
||||
@@ -361,3 +449,6 @@ async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypat
|
||||
"estimates": {},
|
||||
}
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
assert changed_session.status_code == 200
|
||||
assert fetched_session.json() == changed_session.json()
|
||||
assert fetched_session.headers["cache-control"] == "no-store"
|
||||
@@ -0,0 +1,51 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SOURCE = Path(__file__).parents[1] / "frontend" / "today-timer.js"
|
||||
|
||||
|
||||
def test_timer_adopts_claimed_elapsed_time():
|
||||
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 = 100000;
|
||||
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
|
||||
const adopted = timer.adopt('issue:r:42:', 90000, true);
|
||||
now = 105000;
|
||||
process.stdout.write(JSON.stringify({adopted, snapshot:timer.snapshot()}));
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
result = json.loads(completed.stdout)
|
||||
|
||||
assert result == {
|
||||
"adopted": True,
|
||||
"snapshot": {"identity": "issue:r:42:", "elapsed_ms": 95000, "running": True},
|
||||
}
|
||||
|
||||
|
||||
def test_timer_reports_durable_changes_for_session_sync():
|
||||
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 = 0;
|
||||
const changes = [];
|
||||
const timer = createTodayTimer({
|
||||
storage, getLogin:()=> 'timmy', now:()=>now,
|
||||
onChange:snapshot=>changes.push(snapshot),
|
||||
});
|
||||
timer.activate('issue:r:42:');
|
||||
now = 5000;
|
||||
timer.pause();
|
||||
process.stdout.write(JSON.stringify(changes));
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
|
||||
assert json.loads(completed.stdout) == [
|
||||
{"identity": "issue:r:42:", "elapsed_ms": 0, "running": True},
|
||||
{"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
|
||||
]
|
||||
Reference in new issue
Block a user