stackchain-dashboard/tests/test_offline_work.py
timmy 0cf88d7d7a
All checks were successful
CI / lint (pull_request) Successful in 49s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
feat: queue offline pull request reviews (#419)
2026-08-09 19:04:28 +00:00

286 lines
13 KiB
Python

import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
OFFLINE_WORK = Path(__file__).parents[1] / "frontend" / "offline-work.js"
def run_scenario(scenario: str) -> dict:
script = f"""
const createOfflineWorkStore = require({json.dumps(str(OFFLINE_WORK))});
const values = new Map();
const storage = {{
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
{scenario}
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
return json.loads(result.stdout)
def test_opted_in_snapshot_survives_restart_with_only_queue_card_fields():
result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
store.setEnabled(true);
store.save({
user:{login:'timmy', full_name:'Timmy', token:'secret'},
issues:[{id:1, number:8, title:'Ship offline work', body:'private body', state:'open',
repository:'stackchain/dashboard', labels:['P0'], assignees:['timmy'],
updated_at:'2026-08-07T11:00:00Z', url:'https://forge.example/issues/8'}],
pull_requests:[],
notifications:[{id:9, number:8, title:'Updated', unread:true, subject_body:'private comment',
repository:'stackchain/dashboard', subject_type:'Issue', updated_at:'2026-08-07T11:30:00Z',
url:'https://forge.example/issues/8'}],
work_pagination:{issue:{page:1,total:1,has_more:false}},
repos:[{private:true}], events:[{body:'private event'}],
});
const restarted = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:01:00Z')});
process.stdout.write(JSON.stringify({loaded:restarted.load('timmy'), raw:[...values.values()].join(' ')}));
""")
assert result["loaded"]["user"] == {"login": "timmy", "full_name": "Timmy"}
assert result["loaded"]["issues"][0]["title"] == "Ship offline work"
assert result["loaded"]["notifications"][0]["unread"] is True
assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
assert "private body" not in result["raw"]
assert "private comment" not in result["raw"]
assert "private event" not in result["raw"]
assert "secret" not in result["raw"]
def test_expired_snapshot_is_deleted_and_wrong_user_cannot_load_it():
result = run_scenario("""
const writer = createOfflineWorkStore({storage, now:() => new Date('2026-08-01T12:00:00Z'), maxAgeMs:1000});
writer.setEnabled(true);
writer.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
const wrongUser = writer.load('alexander');
const expired = createOfflineWorkStore({storage, now:() => new Date('2026-08-01T12:00:02Z'), maxAgeMs:1000});
const expiredValue = expired.load('timmy');
process.stdout.write(JSON.stringify({wrongUser, expiredValue, snapshotStillStored:values.has('stackchain.offline-work.snapshot.v1')}));
""")
assert result == {
"wrongUser": None,
"expiredValue": None,
"snapshotStillStored": False,
}
def test_opting_out_deletes_the_persisted_snapshot():
result = run_scenario("""
const store = createOfflineWorkStore({storage});
store.setEnabled(true);
store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
store.setEnabled(false);
process.stdout.write(JSON.stringify({enabled:store.enabled(), loaded:store.load()}));
""")
assert result == {"enabled": False, "loaded": None}
def test_opted_in_detail_cache_is_allowlisted_account_bound_and_bounded():
result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z'), maxDetails:2});
store.setEnabled(true);
const issue = {kind:'issue', repository:'stackchain/dashboard', number:8};
store.saveDetail('timmy', issue, {
title:'Ship offline work', body:'Safe description', state:'open', labels:['P0'], assignees:['timmy'],
url:'https://forge.example/issues/8', token:'secret', files:[{patch:'private diff'}],
conversation:{comments:[{id:1, author:'alexander', body:'Newest message', created_at:'2026-08-07T11:00:00Z',
token:'comment-secret'}], page:1, older_page:null, total:1},
});
store.saveDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:9}, {title:'PR 9', body:'Body 9'});
store.saveDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:10}, {title:'Issue 10', body:'Body 10'});
const raw = [...values.values()].join(' ');
process.stdout.write(JSON.stringify({
evicted:store.loadDetail('timmy', issue),
loaded:store.loadDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:9}),
wrongUser:store.loadDetail('alexander', {kind:'pull', repository:'stackchain/dashboard', number:9}),
raw,
}));
""")
assert result["evicted"] is None
assert result["wrongUser"] is None
assert result["loaded"]["title"] == "PR 9"
assert result["loaded"]["saved_at"] == "2026-08-07T12:00:00.000Z"
assert "secret" not in result["raw"]
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"]
def test_requested_review_cache_is_sha_scoped_allowlisted_and_bounded():
result = run_scenario("""
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
store.setEnabled(true);
const review = {kind:'pull', repository:'stackchain/dashboard', number:12, is_review:true};
store.saveDetail('timmy', review, {
title:'Review offline work', body:'Review description', author:'alexander', head_sha:'abc123',
ci_state:'success', token:'top-secret', source_updated_at:'2026-08-07T11:00:00Z',
files:Array.from({length:55}, (_, fileIndex) => ({
filename:'src/file-' + fileIndex + '.js', status:'modified', additions:2, deletions:1,
diff_available:true, diff_binary:false, diff_truncated:false, patch:'must-not-persist',
diff_lines:Array.from({length:405}, (_, lineIndex) => '+' + fileIndex + ':' + lineIndex),
})),
reviews:Array.from({length:25}, (_, index) => ({
id:index + 1, state:'COMMENT', body:'Review ' + (index + 1),
submitted_at:'2026-08-07T10:00:00Z', user:{login:'reviewer-' + index, email:'private@example.test'},
token:'review-secret',
})),
});
const loaded = store.loadDetail('timmy', review);
process.stdout.write(JSON.stringify({
loaded,
wrongUser:store.loadDetail('alexander', review),
raw:[...values.values()].join(' '),
}));
""")
assert result["wrongUser"] is None
assert result["loaded"]["head_sha"] == "abc123"
assert result["loaded"]["ci_state"] == "success"
assert len(result["loaded"]["files"]) == 50
assert len(result["loaded"]["files"][0]["diff_lines"]) == 400
assert result["loaded"]["files"][0]["filename"] == "src/file-0.js"
assert len(result["loaded"]["reviews"]) == 20
assert result["loaded"]["reviews"][0]["body"] == "Review 6"
assert result["loaded"]["reviews"][0]["user"] == {"login": "reviewer-5"}
assert "top-secret" not in result["raw"]
assert "must-not-persist" not in result["raw"]
assert "private@example.test" not in result["raw"]
assert "review-secret" not in result["raw"]
@pytest.mark.anyio
async def test_dashboard_offers_private_offline_work_controls_and_read_only_hydration():
html = await dashboard()
assert '<script src="static/offline-work.js"></script>' in html
assert 'id="keep-work-offline"' in html
assert 'Keep My Work available offline' in html
assert 'id="clear-offline-work"' in html
assert 'id="offline-work-status"' in html
assert 'offlineWorkStore.save({' in html
assert 'offlineWorkStore.load()' in html
assert 'Offline · saved ' in html
assert "setOfflineWorkMode(true)" in html
assert "if (offlineWorkMode)" in html
assert '.offline-work-controls button { min-height:44px;' in html
@pytest.mark.anyio
async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snapshot():
html = await dashboard()
assert "if (!hasContextSnapshot && hydrateOfflineWork('outage')) return;" in html
assert "Outage · saved " in html
assert "Server unavailable · showing private My Work saved " in html
assert "Live details and actions will return automatically." in html
assert "function renderLiveSnapshot(snapshot, changedSections" in html
assert "setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html
@pytest.mark.anyio
async def test_saved_today_details_open_offline_without_enabling_server_state_actions():
html = await dashboard()
assert "offlineWorkStore.loadDetail(offlineLogin, item)" in html
assert "Details not saved—reconnect to open this item." in html
assert "openIssueSheet(item, trigger, savedDetail)" in html
assert "openPullSheet(item, trigger, savedDetail)" in html
assert "offlineWorkStore.saveDetail(confirmedOwnerLogin, item, detail)" in html
assert "todayWork.contains(item)" in html
assert "Offline copy · saved " in html
assert "setOfflineDetailControls('issue')" in html
assert "setOfflineDetailControls('pull')" in html
assert "qs('#issue-planning').inert = true;" in html
assert "qs('#issue-handoff').inert = true;" in html
assert "qs('#pull-review').inert = true;" in html
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_queued_reply_and_read_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 = false;" in html
assert "offline ? 'Queue read & next' : 'Mark read & next'" 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 "replies and read acknowledgements queue for sync" in html
@pytest.mark.anyio
async def test_saved_requested_review_queues_complete_sha_scoped_feedback_offline():
html = await dashboard()
assert "item.is_review ? reviewController.load(item)" in html
assert "openReviewSheet(item, trigger, savedDetail)" in html
assert "async function openReviewSheet(item, trigger, cachedDetail = null)" in html
assert "cachedDetail || await reviewController.load(selectedReview)" in html
assert "Offline review · saved " in html
assert "Queue review for reconnect" in html
assert "kind: 'pull-review'" in html
assert "await authoredOutbox.enqueueDurably" in html
assert "expectedHeadSha: selectedReviewHead" in html
assert "comments: snapshot.comments" in html
assert "draftKey: draft.storageKey" in html
assert "progressKey: progress?.storageKey" in html
assert "draftFingerprint: localStorage.getItem(draft.storageKey) || ''" in html
assert "progressFingerprint: localStorage.getItem(progress?.storageKey) || ''" in html
assert "Review queued · it will submit after reconnect." in html
assert "if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);" in html