feat: continue unread updates offline (#347)
CI / lint (pull_request) Successful in 36s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-08 22:42:20 +00:00
1 parent 0539b0a035
commit ae6cde29c2
11 files changed
+161 -14

No files matched your search

+1 -1
View File
@@ -137,4 +137,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-v39" in worker
assert "stackchain-dashboard-shell-v40" in worker
+1 -1
View File
@@ -35,4 +35,4 @@ 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-v39" in worker
assert "stackchain-dashboard-shell-v40" in worker
+47
View File
@@ -2359,6 +2359,53 @@ reader.open(firstItem).then(async () => {{
assert output["states"] == [[41], [90]]
def test_notification_reader_hydrates_saved_conversation_without_server_state_actions():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
const createPager = require({json.dumps(str(CONVERSATION))});
const item = {{kind:'update', notification_id:42, has_update:true}};
const saved = {{
id:42, title:'Saved update', saved_at:'2026-08-07T12:00:00Z',
conversation:{{comments:[{{id:41, body:'Cached'}}], page:2, older_page:1, total:21}},
}};
let detailLoads = 0;
let conversationLoads = 0;
let markReads = 0;
const details = [];
const conversations = [];
const reader = build.createNotificationReader({{
load: async () => {{ detailLoads += 1; throw new Error('network must not run'); }},
loadConversation: async () => {{ conversationLoads += 1; return {{}}; }},
markRead: async () => {{ markReads += 1; }}, createPager,
onOpen: () => {{}}, onDetail: detail => details.push(detail.title),
onConversation: state => conversations.push(state.comments.map(comment => comment.id)),
onItems: () => {{}}, onStatus: () => {{}}, onClose: () => {{}},
}});
(async () => {{
const opened = await reader.open(item, saved);
const older = await reader.loadOlder();
const marked = await reader.markReadAndNext([item]);
process.stdout.write(JSON.stringify({{
opened, older, marked, detailLoads, conversationLoads, markReads, details, conversations,
}}));
}})();
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"opened": True,
"older": False,
"marked": False,
"detailLoads": 0,
"conversationLoads": 0,
"markReads": 0,
"details": ["Saved update"],
"conversations": [[41]],
}
def test_notification_reader_appends_a_confirmed_reply_exactly_once():
script = f"""
const build = require({json.dumps(str(MY_WORK))});
+53
View File
@@ -117,6 +117,43 @@ process.stdout.write(JSON.stringify({
assert "private diff" not in result["raw"]
def test_unread_update_conversation_detail_is_private_bounded_and_account_bound():
result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
store.setEnabled(true);
const update = {kind:'update', notification_id:42};
store.saveDetail('timmy', update, {
id:42, repository:'stackchain/dashboard', title:'Deployment blocked',
subject_type:'Issue', state:'open', subject_body:'Safe subject context',
url:'https://forge.example/issues/8#issuecomment-9', token:'secret',
issue:{number:8, assignees:[], claimable:true}, files:[{patch:'private diff'}],
conversation:{
comments:Array.from({length:25}, (_, index) => ({
id:index + 1, author:'alexander', body:'Message ' + (index + 1),
created_at:'2026-08-07T11:00:00Z', token:'comment-secret',
})),
page:1, older_page:2, total:40,
},
});
const raw = [...values.values()].join(' ');
process.stdout.write(JSON.stringify({
loaded:store.loadDetail('timmy', update),
wrongUser:store.loadDetail('alexander', update),
raw,
}));
""")
assert result["wrongUser"] is None
assert result["loaded"]["title"] == "Deployment blocked"
assert result["loaded"]["subject_body"] == "Safe subject context"
assert result["loaded"]["subject_type"] == "Issue"
assert len(result["loaded"]["conversation"]["comments"]) == 20
assert result["loaded"]["conversation"]["comments"][0]["body"] == "Message 6"
assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
assert "secret" not in result["raw"]
assert "private diff" not in result["raw"]
@pytest.mark.anyio
async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration():
html = await dashboard()
@@ -165,3 +202,19 @@ async def test_saved_today_details_open_offline_without_enabling_server_state_ac
assert "qs('#issue-planning').inert = false;" in html
assert "qs('#pull-review').inert = false;" in html
assert "confirmedOwnerLogin = String(saved.user?.login || '').trim();" in html
@pytest.mark.anyio
async def test_saved_unread_update_opens_offline_with_reply_only_controls():
html = await dashboard()
assert "notificationReader.open(item, savedDetail)" in html
assert "openRoutedWork(item, button);" in html
assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, selectedUpdate, detail)" in html
assert "Offline update · saved " in html
assert "setOfflineUpdateControls(true)" in html
assert "qs('#mark-update-read-next').disabled = offline;" in html
assert "qs('#update-ownership-action').disabled = offline;" in html
assert "qs('#load-older-update-comments').disabled = offline;" in html
assert "document.querySelectorAll('[data-notification-id], [data-later-preset], [data-today-add]')" in html
assert "Reconnect to mark read, take ownership, defer, or load older messages." in html
+8
View File
@@ -41,3 +41,11 @@ def test_readme_documents_bounded_offline_today_details_and_safe_actions():
assert "newest 20 comments" in text
assert "comments can enter the account-bound durable outbox" in text
assert "planning, assignment, review, merge, and close controls remain disabled" in text
def test_readme_documents_offline_unread_update_conversations_and_boundaries():
text = " ".join(README.read_text().split())
assert "previously opened unread update" in text
assert "replies enter the account-bound durable outbox" in text
assert "mark read, ownership, deferral, and older-message loading remain disabled" in text
+3 -3
View File
@@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v39" in source
assert "stackchain-dashboard-shell-v40" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -106,14 +106,14 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v39" in source
assert "stackchain-dashboard-shell-v40" 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-v39" in source
assert "stackchain-dashboard-shell-v40" in source
assert "BASE + 'static/update-ownership.js'" in source