554 lines
24 KiB
Python
554 lines
24 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 run_async_scenario(scenario: str) -> dict:
|
|
script = f"""
|
|
const createOfflineWorkStore = require({json.dumps(str(OFFLINE_WORK))});
|
|
const values = new Map();
|
|
const records = 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),
|
|
}};
|
|
const transaction = async work => work({{
|
|
get: async key => records.get(key),
|
|
getAll: async () => [...records.values()],
|
|
put: async value => records.set(value.id, structuredClone(value)),
|
|
delete: async key => records.delete(key),
|
|
clear: async () => records.clear(),
|
|
}});
|
|
(async () => {{
|
|
{scenario}
|
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
|
"""
|
|
result = subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_indexeddb_commit_persists_snapshot_and_details_without_localstorage_payloads():
|
|
result = run_async_scenario("""
|
|
const store = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:00:00Z')});
|
|
store.setEnabled(true);
|
|
const snapshotSaved = await store.save({
|
|
user:{login:'timmy', full_name:'Timmy'}, issues:[{number:8, title:'Large offline review'}],
|
|
pull_requests:[], notifications:[],
|
|
});
|
|
const item = {kind:'pull', repository:'stackchain/dashboard', number:8, is_review:true};
|
|
const detailSaved = await store.saveDetail('timmy', item, {
|
|
title:'Large offline review', head_sha:'abc123',
|
|
files:[{filename:'large.diff', diff_lines:['+private review line']}],
|
|
});
|
|
const restarted = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:01:00Z')});
|
|
process.stdout.write(JSON.stringify({
|
|
snapshotSaved, detailSaved,
|
|
loaded:await restarted.load('timmy'),
|
|
detail:await restarted.loadDetail('timmy', item),
|
|
localValues:[...values.values()].join(' '),
|
|
recordIds:[...records.keys()].sort(),
|
|
}));
|
|
""")
|
|
|
|
assert result["snapshotSaved"] is True
|
|
assert result["detailSaved"] is True
|
|
assert result["loaded"]["user"]["login"] == "timmy"
|
|
assert result["detail"]["head_sha"] == "abc123"
|
|
assert "Large offline review" not in result["localValues"]
|
|
assert "private review line" not in result["localValues"]
|
|
assert result["recordIds"] == ["detail:timmy:pull:stackchain/dashboard:8", "snapshot"]
|
|
|
|
|
|
def test_legacy_localstorage_payloads_migrate_only_after_indexeddb_commit():
|
|
result = run_async_scenario("""
|
|
values.set('stackchain.offline-work.enabled.v1', 'true');
|
|
values.set('stackchain.offline-work.snapshot.v1', JSON.stringify({
|
|
version:1, user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
|
|
data:{user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]},
|
|
}));
|
|
values.set('stackchain.offline-work.details.v1', JSON.stringify([{
|
|
key:'issue:stackchain/dashboard:9', user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
|
|
data:{title:'Migrated issue'},
|
|
}]));
|
|
const store = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:01:00Z')});
|
|
const snapshot = await store.load('timmy');
|
|
const detail = await store.loadDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:9});
|
|
process.stdout.write(JSON.stringify({
|
|
snapshot, detail,
|
|
legacySnapshot:values.has('stackchain.offline-work.snapshot.v1'),
|
|
legacyDetails:values.has('stackchain.offline-work.details.v1'),
|
|
recordIds:[...records.keys()].sort(),
|
|
}));
|
|
""")
|
|
|
|
assert result["snapshot"]["user"]["login"] == "timmy"
|
|
assert result["detail"]["title"] == "Migrated issue"
|
|
assert result["legacySnapshot"] is False
|
|
assert result["legacyDetails"] is False
|
|
assert result["recordIds"] == ["detail:timmy:issue:stackchain/dashboard:9", "snapshot"]
|
|
|
|
|
|
def test_failed_migration_keeps_legacy_snapshot_readable_and_untouched():
|
|
result = run_async_scenario("""
|
|
values.set('stackchain.offline-work.enabled.v1', 'true');
|
|
values.set('stackchain.offline-work.snapshot.v1', JSON.stringify({
|
|
version:1, user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
|
|
data:{user:{login:'timmy'}, issues:[{number:7, title:'Keep me'}], pull_requests:[], notifications:[]},
|
|
}));
|
|
let attempts = 0;
|
|
const flakyTransaction = async work => {
|
|
attempts += 1;
|
|
if (attempts === 1) throw new Error('quota admission failed');
|
|
return transaction(work);
|
|
};
|
|
const store = createOfflineWorkStore({storage, transaction:flakyTransaction, now:() => new Date('2026-08-07T12:01:00Z')});
|
|
const snapshot = await store.load('timmy');
|
|
process.stdout.write(JSON.stringify({
|
|
snapshot, attempts,
|
|
legacySnapshot:values.has('stackchain.offline-work.snapshot.v1'),
|
|
recordIds:[...records.keys()],
|
|
}));
|
|
""")
|
|
|
|
assert result["snapshot"]["issues"][0]["title"] == "Keep me"
|
|
assert result["legacySnapshot"] is True
|
|
assert result["recordIds"] == []
|
|
|
|
|
|
def test_ready_atomically_prunes_all_expired_indexeddb_records():
|
|
result = run_async_scenario("""
|
|
const writer = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:00:00Z'), maxAgeMs:1000});
|
|
writer.setEnabled(true);
|
|
await writer.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
|
|
await writer.saveDetail('timmy', {kind:'issue', repository:'stackchain/dashboard', number:1}, {title:'One'});
|
|
await writer.saveDetail('timmy', {kind:'pull', repository:'stackchain/dashboard', number:2}, {title:'Two'});
|
|
const expired = createOfflineWorkStore({storage, transaction, now:() => new Date('2026-08-07T12:00:02Z'), maxAgeMs:1000});
|
|
await expired.ready();
|
|
process.stdout.write(JSON.stringify({recordIds:[...records.keys()]}));
|
|
""")
|
|
|
|
assert result["recordIds"] == []
|
|
|
|
|
|
def test_ready_degrades_within_deadline_when_indexeddb_never_settles():
|
|
result = run_async_scenario("""
|
|
values.set('stackchain.offline-work.enabled.v1', 'true');
|
|
values.set('stackchain.offline-work.snapshot.v1', JSON.stringify({
|
|
version:1, user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
|
|
data:{user:{login:'timmy'}, issues:[{number:7, title:'Still available'}], pull_requests:[], notifications:[]},
|
|
}));
|
|
const neverTransaction = async () => new Promise(() => {});
|
|
const store = createOfflineWorkStore({
|
|
storage, transaction:neverTransaction, initializationTimeoutMs:20,
|
|
now:() => new Date('2026-08-07T12:01:00Z'),
|
|
});
|
|
const started = Date.now();
|
|
const outcome = await Promise.race([
|
|
store.ready().then(value => ({value, status:store.status(), saved:store.load('timmy')})),
|
|
new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 100)),
|
|
]);
|
|
process.stdout.write(JSON.stringify({...outcome, elapsed:Date.now() - started,
|
|
legacySnapshot:values.has('stackchain.offline-work.snapshot.v1')}));
|
|
""")
|
|
|
|
assert result.get("timedOut", False) is False
|
|
assert result["value"] is False
|
|
assert result["status"] == {"state": "degraded", "reason": "deadline"}
|
|
assert result["saved"]["issues"][0]["title"] == "Still available"
|
|
assert result["legacySnapshot"] is True
|
|
assert result["elapsed"] < 100
|
|
|
|
|
|
def test_degraded_store_rejects_saves_without_waiting_for_stalled_indexeddb():
|
|
result = run_async_scenario("""
|
|
values.set('stackchain.offline-work.enabled.v1', 'true');
|
|
const neverTransaction = async () => new Promise(() => {});
|
|
const store = createOfflineWorkStore({storage, transaction:neverTransaction, initializationTimeoutMs:10});
|
|
await store.ready();
|
|
const outcome = await Promise.race([
|
|
store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]})
|
|
.then(value => ({value})),
|
|
new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 80)),
|
|
]);
|
|
process.stdout.write(JSON.stringify(outcome));
|
|
""")
|
|
|
|
assert result == {"value": False}
|
|
|
|
|
|
def test_degraded_store_clears_legacy_data_without_waiting_for_indexeddb():
|
|
result = run_async_scenario("""
|
|
values.set('stackchain.offline-work.snapshot.v1', '{"private":"snapshot"}');
|
|
values.set('stackchain.offline-work.details.v1', '[{"private":"detail"}]');
|
|
const neverTransaction = async () => new Promise(() => {});
|
|
const store = createOfflineWorkStore({storage, transaction:neverTransaction, initializationTimeoutMs:10});
|
|
await store.ready();
|
|
const outcome = await Promise.race([
|
|
store.clear().then(value => ({value, keys:[...values.keys()]})),
|
|
new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 80)),
|
|
]);
|
|
process.stdout.write(JSON.stringify(outcome));
|
|
""")
|
|
|
|
assert result == {"value": True, "keys": []}
|
|
|
|
|
|
def test_retry_recovers_durable_saving_after_initialization_deadline():
|
|
result = run_async_scenario("""
|
|
let stalled = true;
|
|
const recoveringTransaction = async work => stalled ? new Promise(() => {}) : transaction(work);
|
|
const store = createOfflineWorkStore({storage, transaction:recoveringTransaction, initializationTimeoutMs:10});
|
|
const initial = await store.ready();
|
|
stalled = false;
|
|
const recovered = await store.retry();
|
|
store.setEnabled(true);
|
|
const saved = await store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
|
|
process.stdout.write(JSON.stringify({initial, recovered, saved, status:store.status(), recordIds:[...records.keys()]}));
|
|
""")
|
|
|
|
assert result == {
|
|
"initial": False,
|
|
"recovered": True,
|
|
"saved": True,
|
|
"status": {"state": "ready", "reason": ""},
|
|
"recordIds": ["snapshot"],
|
|
}
|
|
|
|
|
|
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_dashboard_uses_async_indexeddb_offline_store_end_to_end():
|
|
html = await dashboard()
|
|
|
|
assert "createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB })" in html
|
|
assert "let offlineStorageReady = await offlineWorkStore.ready();" in html
|
|
assert 'id="retry-offline-storage"' in html
|
|
assert "Offline saving unavailable · online work remains live." in html
|
|
assert "await offlineWorkStore.retry()" in html
|
|
assert "async function hydrateOfflineWork" in html
|
|
assert "const saved = await offlineWorkStore.load();" in html
|
|
assert "await offlineWorkStore.save({" in html
|
|
assert "await offlineWorkStore.clear();" in html
|
|
assert "Offline saving unavailable · retry after reconnect." 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 && await 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 close queued." in handler
|
|
assert "The issue remains in Today; retry." in handler
|