323 lines
15 KiB
Python
323 lines
15 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
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_offline_today_review_queues_durably_before_completing_and_advancing():
|
|
html = await dashboard()
|
|
|
|
assert "function reviewingActiveTodayItem()" in html
|
|
assert "offlineReview && reviewingActiveTodayItem() ? 'Queue review & next'" in html
|
|
handler = html.split("qs('#submit-review').addEventListener('click'", 1)[1].split(
|
|
"qs('#continue-review-to-merge').addEventListener", 1
|
|
)[0]
|
|
admission = handler.index("await authoredOutbox.enqueueDurably")
|
|
completion = handler.index("completeTodayItem(selectedReview")
|
|
assert admission < completion
|
|
assert "successMessage: 'Review queued. Next Today item opened.'" in handler
|
|
assert "failureMessage: 'Review queued, but Today still needs completion.'" in handler
|
|
assert "if (!advanced)" in handler
|
|
assert "Review queued, but Today still needs completion." in handler
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_offline_today_issue_queues_closure_before_completing_and_advancing():
|
|
html = await dashboard()
|
|
|
|
assert '<script src="static/offline-issue-close.js"></script>' in html
|
|
assert "const closeOfflineIssue = createOfflineIssueClose({" in html
|
|
assert "enqueueDurably: message => authoredOutbox.enqueueDurably(message)" in html
|
|
assert "completeToday: (item, options) => completeTodayItem(item, options)" in html
|
|
assert "workSession.active() ? 'Queue close & next' : 'Queue issue closure'" in html
|
|
handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
|
|
"qs('#close-pull-sheet').addEventListener", 1
|
|
)[0]
|
|
admission = handler.index("await closeOfflineIssue(closing)")
|
|
sheet_close = handler.index("closeIssueSheet()")
|
|
assert admission < sheet_close
|
|
assert "Issue closure queued for reconnect." in handler
|
|
assert "The issue remains in Today; retry." in handler
|