feat: surface photo-only reply drafts in My Work (Closes #1396)
This commit is contained in:
1 parent
306d6e3056
commit
fa876e1e38
13 files changed
+474
-12
No files matched your search
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile photo-draft journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def test_release_artifact_finds_and_reopens_photo_only_reply_from_mobile_my_work(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must provide exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
|
||||
page = context.new_page()
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Photo draft release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.evaluate(
|
||||
"""() => new Promise((resolve, reject) => {
|
||||
const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);
|
||||
request.onerror=()=>reject(request.error);
|
||||
request.onupgradeneeded=()=>{
|
||||
if (!request.result.objectStoreNames.contains('drafts')) {
|
||||
request.result.createObjectStore('drafts',{keyPath:'id'});
|
||||
}
|
||||
};
|
||||
request.onsuccess=()=>{
|
||||
const transaction=request.result.transaction('drafts','readwrite');
|
||||
transaction.objectStore('drafts').put({
|
||||
id:'timmy:issue:acme%2Fmobile%3A41', version:1, ownerLogin:'timmy',
|
||||
scope:'conversation', kind:'issue', repository:'acme/mobile', number:41,
|
||||
updatedAt:Date.now(), attachments:[{
|
||||
filename:'rack-label.jpg', contentType:'image/jpeg',
|
||||
blob:new Blob(['field evidence'],{type:'image/jpeg'}), note:'Rack label',
|
||||
}],
|
||||
});
|
||||
transaction.oncomplete=resolve;
|
||||
transaction.onerror=()=>reject(transaction.error);
|
||||
};
|
||||
})"""
|
||||
)
|
||||
page.reload(wait_until="networkidle")
|
||||
stored = page.evaluate(
|
||||
"""() => new Promise((resolve,reject)=>{
|
||||
const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);
|
||||
request.onerror=()=>reject(request.error);
|
||||
request.onsuccess=()=>{
|
||||
const values=request.result.transaction('drafts','readonly').objectStore('drafts').getAll();
|
||||
values.onsuccess=()=>resolve(values.result.map(item=>({ownerLogin:item.ownerLogin,kind:item.kind,number:item.number})));
|
||||
values.onerror=()=>reject(values.error);
|
||||
};
|
||||
})"""
|
||||
)
|
||||
assert stored == [{"ownerLogin": "timmy", "kind": "issue", "number": 41}]
|
||||
inventory = page.evaluate(
|
||||
"""() => createConversationReplyDraftStore({indexedDB,getOwnerLogin:()=> 'timmy'}).list()"""
|
||||
)
|
||||
assert len(inventory) == 1
|
||||
expect(page.locator('[data-work-count="draft"]')).to_have_text("1", timeout=10_000)
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
page.locator('[data-mobile-queue="draft"]').click()
|
||||
|
||||
card = page.locator(".draft-card", has_text="acme/mobile#41")
|
||||
expect(card).to_contain_text("1 saved photo")
|
||||
resume = card.get_by_role("button", name="Resume draft")
|
||||
bounds = resume.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
resume.dispatch_event("click")
|
||||
expect(page.locator("#issue-sheet")).to_be_visible()
|
||||
expect(page.locator("#issue-attachment-preview")).to_be_visible()
|
||||
expect(page.locator("#issue-attachment-meta")).to_contain_text("rack-label.jpg")
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -79,6 +79,50 @@ const photos = [
|
||||
}
|
||||
|
||||
|
||||
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))});
|
||||
@@ -109,6 +153,24 @@ const target={{kind:'issue',repository:'stackchain/dashboard',number:1054}};
|
||||
}
|
||||
|
||||
|
||||
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))});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "photo-draft-inbox.js"
|
||||
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_photo_draft_inbox_combines_stores_without_duplicate_target_cards_and_discards_exact_bundle():
|
||||
script = f"""
|
||||
const createPhotoDraftInbox = require({json.dumps(str(MODULE))});
|
||||
const removed=[];
|
||||
const conversation={{
|
||||
list:async()=>[
|
||||
{{id:'conversation-issue',kind:'photo-reply',photo_store:'conversation',title:'o/r#7',updated_at:30,route:{{kind:'issue',repository:'o/r',number:7}}}},
|
||||
{{id:'conversation-update',kind:'photo-reply',photo_store:'conversation',title:'Update #8',updated_at:20,route:{{kind:'update',notification_id:8}}}},
|
||||
],
|
||||
remove:async target=>removed.push(['conversation',target]),
|
||||
}};
|
||||
const search={{
|
||||
list:async()=>[
|
||||
{{id:'search-pull',kind:'photo-reply',photo_store:'search',title:'o/r#9',updated_at:40,route:{{kind:'search',target_kind:'pull',repository:'o/r',number:9}}}},
|
||||
],
|
||||
remove:async target=>removed.push(['search',target]),
|
||||
}};
|
||||
const inbox=createPhotoDraftInbox({{conversation,search}});
|
||||
(async()=>{{
|
||||
const drafts=await inbox.refresh([{{kind:'issue-comment',route:{{kind:'issue',repository:'o/r',number:7}}}}]);
|
||||
const description=inbox.description({{kind:'photo-reply',photo_count:2}});
|
||||
const searchTarget=inbox.searchTarget(drafts[0]);
|
||||
await inbox.discard(drafts[0]);
|
||||
process.stdout.write(JSON.stringify({{drafts,description,searchTarget,remaining:inbox.list(),removed}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"drafts": [
|
||||
{
|
||||
"id": "search-pull",
|
||||
"kind": "photo-reply",
|
||||
"photo_store": "search",
|
||||
"title": "o/r#9",
|
||||
"updated_at": 40,
|
||||
"route": {"kind": "search", "target_kind": "pull", "repository": "o/r", "number": 9},
|
||||
},
|
||||
{
|
||||
"id": "conversation-update",
|
||||
"kind": "photo-reply",
|
||||
"photo_store": "conversation",
|
||||
"title": "Update #8",
|
||||
"updated_at": 20,
|
||||
"route": {"kind": "update", "notification_id": 8},
|
||||
},
|
||||
],
|
||||
"description": "2 saved photos",
|
||||
"searchTarget": {"kind": "pull", "repository": "o/r", "number": 9, "title": "o/r#9"},
|
||||
"remaining": [
|
||||
{
|
||||
"id": "conversation-update",
|
||||
"kind": "photo-reply",
|
||||
"photo_store": "conversation",
|
||||
"title": "Update #8",
|
||||
"updated_at": 20,
|
||||
"route": {"kind": "update", "notification_id": 8},
|
||||
}
|
||||
],
|
||||
"removed": [["search", {"kind": "pull", "repository": "o/r", "number": 9}]],
|
||||
}
|
||||
|
||||
|
||||
def test_photo_draft_inbox_keeps_available_store_when_other_inventory_fails():
|
||||
script = f"""
|
||||
const createPhotoDraftInbox = require({json.dumps(str(MODULE))});
|
||||
const inbox=createPhotoDraftInbox({{
|
||||
conversation:{{list:async()=>{{throw new Error('IndexedDB unavailable')}}}},
|
||||
search:{{list:async()=>[{{id:'safe',route:{{kind:'search',target_kind:'issue',repository:'o/r',number:2}},updated_at:1}}]}},
|
||||
}});
|
||||
(async()=>process.stdout.write(JSON.stringify(await inbox.refresh([]))))().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert run_node(script) == [
|
||||
{"id": "safe", "route": {"kind": "search", "target_kind": "issue", "repository": "o/r", "number": 2}, "updated_at": 1}
|
||||
]
|
||||
|
||||
|
||||
def test_mobile_my_work_discovers_reopens_and_discards_photo_only_reply_drafts():
|
||||
index = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
worker = WORKER.read_text()
|
||||
bundle = BUNDLE.read_text()
|
||||
|
||||
assert '<script src="static/photo-draft-inbox.js"></script>' in index
|
||||
assert "BASE + 'static/photo-draft-inbox.js'" in worker
|
||||
assert '"static/photo-draft-inbox.js"' in bundle
|
||||
assert "const photoDraftInbox = createPhotoDraftInbox({" in dashboard
|
||||
assert "draftInbox.list().concat(photoDraftInbox.list(), unfiled)" in dashboard
|
||||
assert "await photoDraftInbox.refresh(draftInbox.list())" in dashboard
|
||||
assert "void refreshPhotoDraftInbox();" in dashboard
|
||||
assert "onChange:() => { void refreshPhotoDraftInbox(); }" in dashboard
|
||||
assert "await searchReplyDraftStore.save(target, attachments);\n void refreshPhotoDraftInbox();" in dashboard
|
||||
assert "photoDraftInbox.description(item)" in dashboard
|
||||
assert "photoDraftInbox.searchTarget(item)" in dashboard
|
||||
assert "await photoDraftInbox.discard(item)" in dashboard
|
||||
@@ -65,6 +65,32 @@ const photo = new Blob(['field-evidence'], {{type:'image/webp'}});
|
||||
}
|
||||
|
||||
|
||||
def test_search_store_lists_only_confirmed_accounts_photo_drafts_as_metadata():
|
||||
script = f"""
|
||||
const createStore = require({json.dumps(str(STORE))});
|
||||
const records = [
|
||||
{{id:'mine',version:1,ownerLogin:'timmy',kind:'pull',repository:'stackchain/dashboard',number:19,updatedAt:220,attachments:[{{filename:'proof.jpg',blob:new Blob(['secret'])}}]}},
|
||||
{{id:'other',version:1,ownerLogin:'alexander',kind:'issue',repository:'stackchain/private',number:3,updatedAt:300,attachments:[{{filename:'private.jpg',blob:new Blob(['private'])}}]}},
|
||||
{{id:'empty',version:1,ownerLogin:'timmy',kind:'issue',repository:'stackchain/dashboard',number:20,updatedAt:400,attachments:[]}},
|
||||
];
|
||||
const transaction=async operation=>operation === 'list' ? records.map(value=>structuredClone(value)) : null;
|
||||
const store=createStore({{transaction,getOwnerLogin:()=> 'timmy'}});
|
||||
(async()=>process.stdout.write(JSON.stringify(await store.list())))().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
assert json.loads(run_node(script)) == [{
|
||||
"id": "mine",
|
||||
"kind": "photo-reply",
|
||||
"label": "Search PR photos",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 19,
|
||||
"title": "stackchain/dashboard#19",
|
||||
"photo_count": 1,
|
||||
"updated_at": 220,
|
||||
"route": {"kind": "search", "target_kind": "pull", "repository": "stackchain/dashboard", "number": 19},
|
||||
"photo_store": "search",
|
||||
}]
|
||||
|
||||
|
||||
def test_photo_bundle_checkpoint_restores_stable_uploads_without_repeating_them():
|
||||
script = f"""
|
||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||
|
||||
@@ -1389,6 +1389,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/search-reply-draft-store.js",
|
||||
"/dashboard/static/conversation-reply-draft-store.js",
|
||||
"/dashboard/static/conversation-photo-drafts.js",
|
||||
"/dashboard/static/photo-draft-inbox.js",
|
||||
"/dashboard/static/search-defer.js",
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
|
||||
Reference in new issue
Block a user