224 lines
10 KiB
Python
224 lines
10 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
STORE = ROOT / "frontend" / "conversation-reply-draft-store.js"
|
|
COORDINATOR = ROOT / "frontend" / "conversation-photo-drafts.js"
|
|
DASHBOARD = ROOT / "frontend" / "dashboard.js"
|
|
INDEX = ROOT / "frontend" / "index.html"
|
|
REGISTRY = ROOT / "frontend" / "private-data-registry.js"
|
|
WORKER = ROOT / "frontend" / "service-worker.js"
|
|
BUNDLE = ROOT / "src" / "frontend_bundle.py"
|
|
|
|
|
|
def run_node(script: str) -> str:
|
|
return subprocess.run(
|
|
["node", "-e", script], check=True, capture_output=True, text=True
|
|
).stdout
|
|
|
|
|
|
def test_store_isolates_complete_photo_bundles_by_account_and_conversation():
|
|
script = f"""
|
|
const createStore = require({json.dumps(str(STORE))});
|
|
const records = new Map();
|
|
let login = 'timmy';
|
|
const transaction = async (operation, key, value) => {{
|
|
if (operation === 'put') records.set(key, structuredClone(value));
|
|
if (operation === 'get') return records.has(key) ? structuredClone(records.get(key)) : null;
|
|
if (operation === 'delete') records.delete(key);
|
|
}};
|
|
const store = createStore({{transaction, getOwnerLogin:()=>login}});
|
|
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:963}};
|
|
const pull = {{kind:'pull', repository:'stackchain/dashboard', number:963}};
|
|
const update = {{kind:'update', notificationId:4815}};
|
|
const photos = [
|
|
{{filename:'before.webp', contentType:'image/webp', blob:new Blob(['before']), note:'before', operationId:'op-1'}},
|
|
{{filename:'after.webp', contentType:'image/webp', blob:new Blob(['after']), note:'after', operationId:'op-2', confirmed:{{markdown:''}}}},
|
|
];
|
|
(async()=>{{
|
|
await store.save(issue, photos);
|
|
await store.save(pull, [photos[1]]);
|
|
await store.save(update, [photos[0]]);
|
|
const restored = await store.load(issue);
|
|
const pullDraft = await store.load(pull);
|
|
const updateDraft = await store.load(update);
|
|
login = 'alexander';
|
|
const wrongAccount = await store.load(issue);
|
|
process.stdout.write(JSON.stringify({{
|
|
restored:await Promise.all(restored.map(async photo=>({{...photo,blobText:await photo.blob.text(),blob:undefined}}))),
|
|
pullCount:pullDraft.length, updateCount:updateDraft.length, wrongAccount, recordCount:records.size,
|
|
}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
output = json.loads(run_node(script))
|
|
|
|
assert output == {
|
|
"restored": [
|
|
{
|
|
"filename": "before.webp",
|
|
"contentType": "image/webp",
|
|
"note": "before",
|
|
"operationId": "op-1",
|
|
"blobText": "before",
|
|
},
|
|
{
|
|
"filename": "after.webp",
|
|
"contentType": "image/webp",
|
|
"note": "after",
|
|
"operationId": "op-2",
|
|
"confirmed": {"markdown": ""},
|
|
"blobText": "after",
|
|
},
|
|
],
|
|
"pullCount": 1,
|
|
"updateCount": 1,
|
|
"wrongAccount": None,
|
|
"recordCount": 3,
|
|
}
|
|
|
|
|
|
def test_store_isolates_today_progress_photos_from_conversation_photos():
|
|
script = f"""
|
|
const createStore = require({json.dumps(str(STORE))});
|
|
const records = new Map();
|
|
const transaction = async (operation, key, value) => {{
|
|
if (operation === 'put') records.set(key, structuredClone(value));
|
|
if (operation === 'get') return records.has(key) ? structuredClone(records.get(key)) : null;
|
|
if (operation === 'delete') records.delete(key);
|
|
}};
|
|
const conversation = createStore({{transaction,getOwnerLogin:()=> 'timmy'}});
|
|
const progress = createStore({{transaction,getOwnerLogin:()=> 'timmy',scope:'today-progress'}});
|
|
const target={{kind:'issue',repository:'stackchain/dashboard',number:1054}};
|
|
(async()=>{{
|
|
await conversation.save(target,[{{filename:'reply.jpg',contentType:'image/jpeg',blob:new Blob(['reply'])}}]);
|
|
await progress.save(target,[{{filename:'progress.jpg',contentType:'image/jpeg',blob:new Blob(['progress'])}}]);
|
|
const reply=await conversation.load(target); const today=await progress.load(target);
|
|
await progress.remove(target);
|
|
process.stdout.write(JSON.stringify({{reply:reply[0].filename,today:today[0].filename,
|
|
replyAfter:(await conversation.load(target))[0].filename,progressAfter:await progress.load(target),count:records.size}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
assert json.loads(run_node(script)) == {
|
|
"reply": "reply.jpg",
|
|
"today": "progress.jpg",
|
|
"replyAfter": "reply.jpg",
|
|
"progressAfter": None,
|
|
"count": 1,
|
|
}
|
|
|
|
|
|
def test_coordinator_ignores_stale_restores_and_clears_only_after_durable_completion():
|
|
script = f"""
|
|
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
|
const waits = new Map();
|
|
const removed = [];
|
|
const restored = [];
|
|
let current = [{{filename:'current.webp',contentType:'image/webp',blob:new Blob(['current'])}}];
|
|
const store = {{
|
|
load:target=>new Promise(resolve=>waits.set(target.number, resolve)),
|
|
save:async()=>null,
|
|
remove:async target=>removed.push(target.number || target.notificationId),
|
|
}};
|
|
const controller = {{
|
|
serialize:async()=>current,
|
|
restore:value=>restored.push(value[0].filename),
|
|
clear:()=>{{current=[];}},
|
|
}};
|
|
const drafts = createCoordinator({{store, lanes:{{issue:{{controller,onError:()=>{{}}}}}}}});
|
|
(async()=>{{
|
|
const first = drafts.open('issue', {{kind:'issue',repository:'stackchain/dashboard',number:1}});
|
|
const second = drafts.open('issue', {{kind:'issue',repository:'stackchain/dashboard',number:2}});
|
|
await Promise.resolve();
|
|
waits.get(1)([{{filename:'stale.webp',contentType:'image/webp',blob:new Blob(['stale'])}}]);
|
|
waits.get(2)([{{filename:'fresh.webp',contentType:'image/webp',blob:new Blob(['fresh'])}}]);
|
|
await Promise.all([first, second]);
|
|
await drafts.complete('issue');
|
|
process.stdout.write(JSON.stringify({{restored,removed,hasTarget:drafts.hasTarget('issue')}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
output = json.loads(run_node(script))
|
|
assert output == {"restored": ["fresh.webp"], "removed": [2], "hasTarget": False}
|
|
|
|
|
|
def test_coordinator_checkpoints_previous_target_before_switching_conversations():
|
|
script = f"""
|
|
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
|
const events = [];
|
|
let current = [{{filename:'first.webp',contentType:'image/webp',blob:new Blob(['first'])}}];
|
|
const store = {{
|
|
load:async target=>target.number === 2 ? [{{filename:'second.webp',contentType:'image/webp',blob:new Blob(['second'])}}] : null,
|
|
save:async (target,attachments)=>events.push('save:' + target.number + ':' + attachments[0].filename),
|
|
remove:async()=>null,
|
|
}};
|
|
const controller = {{
|
|
serialize:async()=>current,
|
|
restore:value=>{{current=value;events.push('restore:' + value[0].filename);}},
|
|
clear:()=>{{current=[];}},
|
|
}};
|
|
const drafts = createCoordinator({{store, lanes:{{issue:{{controller}}}}}});
|
|
(async()=>{{
|
|
await drafts.open('issue', {{kind:'issue',repository:'stackchain/dashboard',number:1}});
|
|
current=[{{filename:'first.webp',contentType:'image/webp',blob:new Blob(['first'])}}];
|
|
await drafts.switchTo('issue', {{kind:'issue',repository:'stackchain/dashboard',number:2}});
|
|
process.stdout.write(JSON.stringify(events));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
assert json.loads(run_node(script)) == ["save:1:first.webp", "restore:second.webp"]
|
|
|
|
|
|
def test_coordinator_clear_does_not_delete_the_durable_draft_during_reopen():
|
|
script = f"""
|
|
const createCoordinator = require({json.dumps(str(COORDINATOR))});
|
|
const records=new Map(); let current=[]; let drafts; const restored=[];
|
|
const key=target=>target.kind+':'+target.number;
|
|
const store={{
|
|
save:async(target,attachments)=>attachments?.length?records.set(key(target),attachments):records.delete(key(target)),
|
|
load:async target=>records.get(key(target))||null,
|
|
remove:async target=>records.delete(key(target)),
|
|
}};
|
|
const controller={{
|
|
serialize:async()=>current,
|
|
restore:value=>{{current=value;restored.push(value[0].filename);}},
|
|
clear:()=>{{current=[];drafts.checkpoint('today').catch(()=>{{}});}},
|
|
}};
|
|
drafts=createCoordinator({{store,lanes:{{today:{{controller}}}}}});
|
|
const target={{kind:'issue',repository:'stackchain/dashboard',number:1054}};
|
|
(async()=>{{
|
|
await drafts.open('today',target);
|
|
current=[{{filename:'proof.webp',contentType:'image/webp',blob:new Blob(['proof'])}}];
|
|
await drafts.checkpoint('today');
|
|
await drafts.switchTo('today',target);
|
|
await Promise.resolve();
|
|
process.stdout.write(JSON.stringify({{restored,saved:(await store.load(target))?.[0]?.filename||null}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
assert json.loads(run_node(script)) == {"restored": ["proof.webp"], "saved": "proof.webp"}
|
|
|
|
|
|
def test_my_work_wires_durable_photo_drafts_into_all_conversation_boundaries():
|
|
dashboard = DASHBOARD.read_text()
|
|
html = INDEX.read_text()
|
|
registry = REGISTRY.read_text()
|
|
worker = WORKER.read_text()
|
|
bundle = BUNDLE.read_text()
|
|
|
|
assert '<script src="static/conversation-reply-draft-store.js"></script>' in html
|
|
assert '<script src="static/conversation-photo-drafts.js"></script>' in html
|
|
assert "'stackchain-conversation-reply-drafts-v1'" in registry
|
|
assert "BASE + 'static/conversation-reply-draft-store.js'" in worker
|
|
assert "BASE + 'static/conversation-photo-drafts.js'" in worker
|
|
assert '"static/conversation-reply-draft-store.js"' in bundle
|
|
assert '"static/conversation-photo-drafts.js"' in bundle
|
|
assert "conversationPhotoDrafts.switchTo('issue'," in dashboard
|
|
assert "conversationPhotoDrafts.switchTo('pull'," in dashboard
|
|
assert "conversationPhotoDrafts.switchTo('update'," in dashboard
|
|
assert "conversationPhotoDrafts.leave('issue')" in dashboard
|
|
assert "conversationPhotoDrafts.leave('pull')" in dashboard
|
|
assert "conversationPhotoDrafts.leave('update')" in dashboard
|
|
assert "await conversationPhotoDrafts.complete(kind)" in dashboard
|
|
assert "await conversationPhotoDrafts.complete('issue')" in dashboard
|
|
assert "await conversationPhotoDrafts.complete('pull')" in dashboard
|
|
assert "await conversationPhotoDrafts.complete('update')" in dashboard
|