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_lists_only_confirmed_accounts_conversation_photo_drafts_without_blobs():
script = f"""
const createStore = require({json.dumps(str(STORE))});
const records = new Map([
['timmy:issue:stackchain%2Fdashboard%3A7', {{id:'a',version:1,ownerLogin:'timmy',scope:'conversation',kind:'issue',repository:'stackchain/dashboard',number:7,updatedAt:100,attachments:[{{filename:'rack.jpg',blob:new Blob(['secret'])}},{{filename:'label.jpg',blob:new Blob(['secret'])}}]}},],
['timmy:update:8', {{id:'b',version:1,ownerLogin:'timmy',scope:'conversation',kind:'update',notificationId:8,updatedAt:200,attachments:[{{filename:'alert.jpg',blob:new Blob(['secret'])}}]}},],
['alexander:pull:o%2Fr%3A9', {{id:'c',version:1,ownerLogin:'alexander',scope:'conversation',kind:'pull',repository:'o/r',number:9,updatedAt:300,attachments:[{{filename:'private.jpg',blob:new Blob(['private'])}}]}},],
['broken', {{id:'broken',version:1,ownerLogin:'timmy',scope:'conversation',kind:'issue',repository:'',number:0,updatedAt:400,attachments:[{{filename:'bad.jpg',blob:new Blob(['bad'])}}]}},],
]);
const transaction = async (operation, key, value) => {{
if (operation === 'list') return Array.from(records.values()).map(value => structuredClone(value));
}};
const store = createStore({{transaction,getOwnerLogin:()=> 'timmy'}});
(async()=>{{
const drafts=await store.list();
process.stdout.write(JSON.stringify(drafts));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert json.loads(run_node(script)) == [
{
"id": "b",
"kind": "photo-reply",
"label": "Update photos",
"title": "Update #8",
"photo_count": 1,
"updated_at": 200,
"route": {"kind": "update", "notification_id": 8},
"photo_store": "conversation",
},
{
"id": "a",
"kind": "photo-reply",
"label": "Issue photos",
"repository": "stackchain/dashboard",
"number": 7,
"title": "stackchain/dashboard#7",
"photo_count": 2,
"updated_at": 100,
"route": {"kind": "issue", "repository": "stackchain/dashboard", "number": 7},
"photo_store": "conversation",
},
]
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_notifies_inventory_after_checkpoint_and_completion():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const changes=[];
let current=[{{filename:'proof.jpg',blob:new Blob(['proof'])}}];
const store={{save:async()=>null,load:async()=>null,remove:async()=>null}};
const controller={{serialize:async()=>current,restore:()=>{{}},clear:()=>{{current=[];}}}};
const drafts=createCoordinator({{store,lanes:{{issue:{{controller}}}},onChange:()=>changes.push('changed')}});
(async()=>{{
await drafts.open('issue',{{kind:'issue',repository:'o/r',number:7}});
await drafts.checkpoint('issue');
await drafts.complete('issue');
process.stdout.write(JSON.stringify(changes));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert json.loads(run_node(script)) == ["changed", "changed"]
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 '' in html
assert '' 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