221 lines
9.6 KiB
Python
221 lines
9.6 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"]
|
|
|
|
|
|
@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_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
|