feat: preserve My Work photo drafts (Closes #963)
This commit is contained in:
1 parent
50f1af83f6
commit
31f2c71575
13 files changed
+432
-29
No files matched your search
@@ -0,0 +1,164 @@
|
||||
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_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_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
|
||||
@@ -350,7 +350,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == [
|
||||
@@ -377,7 +377,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
@@ -509,7 +509,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -533,7 +533,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -630,7 +630,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
|
||||
@@ -979,7 +979,8 @@ def test_issue_comment_actions_upload_binary_multipart_before_posting_and_clear_
|
||||
assert "'/attachments'" in source
|
||||
assert "issueAttachmentController.prepareComment(item, body)" in source
|
||||
assert "issueAttachmentController.prepareComment(selectedIssue, body)" in source
|
||||
assert source.count("issueAttachmentController.clear();") >= 2
|
||||
assert "await conversationPhotoDrafts.complete('issue')" in source
|
||||
assert "await conversationPhotoDrafts.complete(kind)" in source
|
||||
assert "'Idempotency-Key': payload.operation_id" in source
|
||||
assert "body: issueAttachment.multipart(payload)" in source
|
||||
|
||||
@@ -991,7 +992,7 @@ def test_closing_issue_sheet_cannot_carry_a_screenshot_to_another_issue():
|
||||
source,
|
||||
re.DOTALL,
|
||||
).group("body")
|
||||
assert "issueAttachmentController.clear();" in close_body
|
||||
assert "conversationPhotoDrafts.leave('issue')" in close_body
|
||||
|
||||
|
||||
def test_attachment_runtime_is_available_in_the_offline_app_shell():
|
||||
@@ -1034,16 +1035,17 @@ def test_assigned_pull_composer_offers_screenshot_preview_remove_and_reuses_opti
|
||||
assert 'id="pull-attachment-image"' in html
|
||||
assert 'id="remove-pull-attachment"' in html
|
||||
assert "const pullAttachmentController = issueAttachment.mount({" in source
|
||||
assert "pullAttachmentController.clear();" in source
|
||||
assert "await conversationPhotoDrafts.complete('pull')" in source
|
||||
|
||||
|
||||
def test_retrying_same_pull_load_preserves_screenshot_but_target_change_clears_it():
|
||||
def test_retrying_same_pull_load_restores_only_the_exact_target_draft():
|
||||
source = DASHBOARD.read_text()
|
||||
open_body = source.split("async function openPullSheet(item, trigger, offlineDetail = null) {", 1)[1].split(
|
||||
"\n function closePullSheet", 1
|
||||
)[0]
|
||||
|
||||
assert "if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear();" in open_body
|
||||
assert "conversationPhotoDrafts.switchTo('pull'," in open_body
|
||||
assert "() => selectedPull === item" in open_body
|
||||
|
||||
|
||||
def test_pull_screenshot_comment_uploads_or_durably_admits_before_clearing_draft():
|
||||
|
||||
@@ -32,6 +32,7 @@ process.stdout.write(JSON.stringify(databases));
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ const clear = createPrivateDeviceDataPurger({{
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
|
||||
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
|
||||
@@ -431,6 +431,7 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert result["state"]["clientMessages"] == [
|
||||
@@ -525,6 +526,7 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert result["replies"] == [{"ok": True}]
|
||||
|
||||
@@ -942,6 +944,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/saved-searches.js",
|
||||
"/dashboard/static/search-preview.js",
|
||||
"/dashboard/static/search-reply-draft-store.js",
|
||||
"/dashboard/static/conversation-reply-draft-store.js",
|
||||
"/dashboard/static/conversation-photo-drafts.js",
|
||||
"/dashboard/static/search-defer.js",
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
@@ -1131,6 +1135,7 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
|
||||
"stackchain-unfiled-captures-v1",
|
||||
"stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
]
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert "private cached dashboard" not in result["body"]
|
||||
|
||||
Reference in new issue
Block a user