From fa876e1e383d6e4f1987630dfd9035a43d159607 Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 25 Aug 2026 13:14:54 +0000 Subject: [PATCH] feat: surface photo-only reply drafts in My Work (Closes #1396) --- frontend/conversation-photo-drafts.js | 9 +- frontend/conversation-reply-draft-store.js | 42 ++++++- frontend/dashboard.js | 20 +++- frontend/index.html | 1 + frontend/photo-draft-inbox.js | 80 +++++++++++++ frontend/search-reply-draft-store.js | 31 ++++- frontend/service-worker.js | 1 + src/frontend_bundle.py | 4 +- .../test_mobile_photo_draft_inbox_release.py | 99 ++++++++++++++++ tests/test_conversation_photo_drafts.py | 62 ++++++++++ tests/test_photo_draft_inbox.py | 110 ++++++++++++++++++ tests/test_search_reply_draft_store.py | 26 +++++ tests/test_service_worker.py | 1 + 13 files changed, 474 insertions(+), 12 deletions(-) create mode 100644 frontend/photo-draft-inbox.js create mode 100644 tests/e2e/test_mobile_photo_draft_inbox_release.py create mode 100644 tests/test_photo_draft_inbox.py diff --git a/frontend/conversation-photo-drafts.js b/frontend/conversation-photo-drafts.js index ad052d1..dd3e1a8 100644 --- a/frontend/conversation-photo-drafts.js +++ b/frontend/conversation-photo-drafts.js @@ -5,7 +5,7 @@ })(typeof globalThis !== 'undefined' ? globalThis : this, function () { 'use strict'; - return function createConversationPhotoDrafts({ store, lanes }) { + return function createConversationPhotoDrafts({ store, lanes, onChange = () => {} }) { const states = {}; Object.entries(lanes || {}).forEach(([kind, lane]) => { states[kind] = { ...lane, target:null, generation:0, restoring:false, pending:Promise.resolve() }; @@ -24,7 +24,11 @@ const attachments = await current.controller.serialize(); const save = current.pending.then(() => store.save(target, attachments)); current.pending = save.catch(() => null); - try { return await save; } + try { + const saved = await save; + onChange(); + return saved; + } catch (error) { current.onError?.(error); throw error; } } @@ -67,6 +71,7 @@ const target = { ...current.target }; await current.pending; await store.remove(target); + onChange(); current.restoring = true; try { current.controller.clear(); } finally { diff --git a/frontend/conversation-reply-draft-store.js b/frontend/conversation-reply-draft-store.js index 0d8b0a3..22271d4 100644 --- a/frontend/conversation-reply-draft-store.js +++ b/frontend/conversation-reply-draft-store.js @@ -33,10 +33,11 @@ } return async (operation, key, value) => { const db = await database(); - const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite'); + const transaction = db.transaction(storeName, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite'); const records = transaction.objectStore(storeName); if (operation === 'put') return requestResult(records.put(value)); if (operation === 'delete') return requestResult(records.delete(key)); + if (operation === 'list') return requestResult(records.getAll()); return requestResult(records.get(key)); }; } @@ -97,7 +98,10 @@ const { id, ownerLogin, scope:recordScope, target:normalized } = identity(target); const list = (Array.isArray(values) ? values : [values]).filter(Boolean).slice(0, 5).map(attachment); if (!list.length) { await transact('delete', id); return null; } - await transact('put', id, { id, version:1, ownerLogin, scope:recordScope, ...normalized, attachments:list }); + await transact('put', id, { + id, version:1, ownerLogin, scope:recordScope, ...normalized, + updatedAt:Date.now(), attachments:list, + }); return list; } @@ -122,6 +126,38 @@ return true; } - return { save, load, remove }; + async function list() { + if (!transact || draftScope !== 'conversation') return []; + const ownerLogin = String(getOwnerLogin() || '').trim(); + if (!ownerLogin) return []; + const records = await transact('list'); + return (Array.isArray(records) ? records : []).flatMap(record => { + if (record?.version !== 1 || record.ownerLogin !== ownerLogin || + (record.scope && record.scope !== 'conversation') || + !Array.isArray(record.attachments) || !record.attachments.length) return []; + const updatedAt = Number(record.updatedAt || 0); + if (record.kind === 'update') { + const notificationId = Number(record.notificationId || 0); + if (!Number.isInteger(notificationId) || notificationId < 1) return []; + return [{ + id:record.id, kind:'photo-reply', label:'Update photos', title:'Update #' + notificationId, + photo_count:record.attachments.length, updated_at:updatedAt, + route:{ kind:'update', notification_id:notificationId }, photo_store:'conversation', + }]; + } + const repository = String(record.repository || ''); + const number = Number(record.number || 0); + if (!['issue', 'pull'].includes(record.kind) || !repository || + !Number.isInteger(number) || number < 1) return []; + const label = record.kind === 'issue' ? 'Issue photos' : 'PR photos'; + return [{ + id:record.id, kind:'photo-reply', label, repository, number, + title:repository + '#' + number, photo_count:record.attachments.length, updated_at:updatedAt, + route:{ kind:record.kind, repository, number }, photo_store:'conversation', + }]; + }).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id)); + } + + return { save, load, remove, list }; }; }); diff --git a/frontend/dashboard.js b/frontend/dashboard.js index f9d00cf..6ea3679 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -777,6 +777,7 @@ try { const attachments = await searchReplyAttachmentController.serialize(); await searchReplyDraftStore.save(target, attachments); + void refreshPhotoDraftInbox(); } catch (error) { qs('#search-preview-reply-status').textContent = error?.message || 'Photos could not be saved. They remain in this preview; retry before leaving.'; @@ -1002,6 +1003,7 @@ } }); const conversationPhotoDrafts = createConversationPhotoDrafts({ store:conversationPhotoDraftStore, + onChange:() => { void refreshPhotoDraftInbox(); }, lanes:{ issue:photoDraftLane(issueAttachmentController, '#issue-comment-status'), pull:photoDraftLane(pullAttachmentController, '#pull-comment-status'), @@ -1310,6 +1312,10 @@ ensurePullWorkflow().then(() => rR.restore()).catch(() => {}); } const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin }); + const photoDraftInbox = createPhotoDraftInbox({ + conversation:conversationPhotoDraftStore, + search:searchReplyDraftStore, + }); outboxCoordinator.subscribe(() => refreshMyWorkView()); const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB }); let offlineStorageReady = await offlineWorkStore.ready(); @@ -3401,11 +3407,16 @@ ownership:item.quarantined ? 'Saved by ' + item.ownerLogin + (activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '', })); - return draftInbox.list().concat(unfiled).sort((left, right) => + return draftInbox.list().concat(photoDraftInbox.list(), unfiled).sort((left, right) => Number(right.updated_at || 0) - Number(left.updated_at || 0) ); } + async function refreshPhotoDraftInbox() { + try { await photoDraftInbox.refresh(draftInbox.list()); refreshMyWorkView({ reconcileSession:false }); } + catch (_error) { /* Local photo inventory must not break My Work. */ } + } + function refreshMyWorkView({ reconcileSession = true } = {}) { lastDrafts = listDrafts(); const actionableMyWork = filedHistoryTabs.prepare(lastMyWork); @@ -3579,7 +3590,7 @@ return '
' + '' + escapeHtml([item.label, item.repository, item.details].filter(Boolean).join(' · ')) + '' + '' + escapeHtml(item.title) + '' + - '' + escapeHtml(item.preview || 'Unfinished draft') + '' + state + attempt + + '' + escapeHtml(photoDraftInbox.description(item)) + '' + state + attempt + 'Saved ' + escapeHtml(fmt(item.updated_at)) + '' + '
' + outboxActions + '
'; }; @@ -3618,6 +3629,9 @@ await dFS.open(item.capture_id, item.ready ? button : null); } catch (error) { qs('#my-work-action-status').textContent = error.message; } } else if (item.kind === 'new-issue') openCreateIssueSheet(); + else if (item.kind === 'photo-reply' && item.route?.kind === 'search') { + taskOverlayHistory.open('search-preview', { preview:photoDraftInbox.searchTarget(item) }); + } else if (item.route) workRoute.open(item.route); }; }); @@ -3736,6 +3750,7 @@ } else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id); else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id); + else if (item?.kind === 'photo-reply') await photoDraftInbox.discard(item); else if (item) draftInbox.discard(item.id); lastDrafts = listDrafts(); const count = qs('[data-work-count="draft"]'); @@ -5493,6 +5508,7 @@ activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : ''; if (activeFlushLogin) { confirmedOwnerLogin = activeFlushLogin; + void refreshPhotoDraftInbox(); timerView.restore(todaySync.flush()); restoreReleaseReceipt(); updateDeliveryReceiptControls(); diff --git a/frontend/index.html b/frontend/index.html index 5dcfebe..5900a92 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2264,6 +2264,7 @@ + diff --git a/frontend/photo-draft-inbox.js b/frontend/photo-draft-inbox.js new file mode 100644 index 0000000..72eae21 --- /dev/null +++ b/frontend/photo-draft-inbox.js @@ -0,0 +1,80 @@ +(function (root, factory) { + const createPhotoDraftInbox = factory(); + if (typeof module === 'object' && module.exports) module.exports = createPhotoDraftInbox; + else root.createPhotoDraftInbox = createPhotoDraftInbox; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + function targetKey(route) { + if (route?.kind === 'update') { + const notificationId = Number(route.notification_id || 0); + return notificationId > 0 ? 'update:' + notificationId : ''; + } + const kind = route?.kind === 'search' ? route.target_kind : route?.kind; + const repository = String(route?.repository || ''); + const number = Number(route?.number || 0); + return ['issue', 'pull'].includes(kind) && repository && number > 0 ? + kind + ':' + repository.toLowerCase() + '#' + number : ''; + } + + function removeTarget(item) { + const route = item?.route || {}; + if (route.kind === 'update') return { kind:'update', notificationId:Number(route.notification_id) }; + return { + kind:route.kind === 'search' ? route.target_kind : route.kind, + repository:route.repository, + number:Number(route.number), + }; + } + + return function createPhotoDraftInbox({ conversation, search }) { + let items = []; + + async function refresh(existing = []) { + const occupied = new Set(existing.map(item => targetKey(item?.route)).filter(Boolean)); + const results = await Promise.allSettled([ + conversation?.list?.() || [], + search?.list?.() || [], + ]); + items = results.flatMap(result => result.status === 'fulfilled' && Array.isArray(result.value) ? result.value : []) + .filter(item => { + const key = targetKey(item?.route); + if (!key || occupied.has(key)) return false; + occupied.add(key); + return true; + }) + .sort((left, right) => Number(right.updated_at || 0) - Number(left.updated_at || 0) || + String(left.id || '').localeCompare(String(right.id || ''))); + return items.slice(); + } + + function list() { return items.slice(); } + + async function discard(item) { + if (!item || !items.some(candidate => candidate.id === item.id && candidate.photo_store === item.photo_store)) { + return false; + } + const store = item.photo_store === 'search' ? search : conversation; + if (!store?.remove) return false; + await store.remove(removeTarget(item)); + items = items.filter(candidate => !(candidate.id === item.id && candidate.photo_store === item.photo_store)); + return true; + } + + function description(item) { + return item?.kind === 'photo-reply' ? + Number(item.photo_count || 0) + (Number(item.photo_count) === 1 ? ' saved photo' : ' saved photos') : + (item?.preview || 'Unfinished draft'); + } + + function searchTarget(item) { + const route = item?.route || {}; + return { + kind:route.target_kind, repository:route.repository, + number:route.number, title:item?.title, + }; + } + + return { refresh, list, discard, description, searchTarget }; + }; +}); diff --git a/frontend/search-reply-draft-store.js b/frontend/search-reply-draft-store.js index 9fa78e1..02ae246 100644 --- a/frontend/search-reply-draft-store.js +++ b/frontend/search-reply-draft-store.js @@ -33,10 +33,11 @@ } return async (operation, key, value) => { const db = await database(); - const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite'); + const transaction = db.transaction(storeName, ['get', 'list'].includes(operation) ? 'readonly' : 'readwrite'); const records = transaction.objectStore(storeName); if (operation === 'put') return requestResult(records.put(value)); if (operation === 'delete') return requestResult(records.delete(key)); + if (operation === 'list') return requestResult(records.getAll()); return requestResult(records.get(key)); }; } @@ -95,7 +96,9 @@ await transact('delete', id); return null; } - await transact('put', id, { id, version:1, ownerLogin, ...normalized, attachments:list }); + await transact('put', id, { + id, version:1, ownerLogin, ...normalized, updatedAt:Date.now(), attachments:list, + }); return list; } @@ -116,6 +119,28 @@ return true; } - return { save, load, remove }; + async function list() { + if (!transact) return []; + const ownerLogin = String(getOwnerLogin() || '').trim(); + if (!ownerLogin) return []; + const records = await transact('list'); + return (Array.isArray(records) ? records : []).flatMap(record => { + const repository = String(record?.repository || ''); + const number = Number(record?.number || 0); + if (record?.version !== 1 || record.ownerLogin !== ownerLogin || + !['issue', 'pull'].includes(record.kind) || !repository || + !Number.isInteger(number) || number < 1 || + !Array.isArray(record.attachments) || !record.attachments.length) return []; + return [{ + id:record.id, kind:'photo-reply', + label:'Search ' + (record.kind === 'pull' ? 'PR' : 'issue') + ' photos', + repository, number, title:repository + '#' + number, + photo_count:record.attachments.length, updated_at:Number(record.updatedAt || 0), + route:{ kind:'search', target_kind:record.kind, repository, number }, photo_store:'search', + }]; + }).sort((left, right) => Number(right.updated_at) - Number(left.updated_at) || left.id.localeCompare(right.id)); + } + + return { save, load, remove, list }; }; }); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 6165458..f08ca32 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -167,6 +167,7 @@ const SHELL = [ BASE + 'static/search-reply-draft-store.js', BASE + 'static/conversation-reply-draft-store.js', BASE + 'static/conversation-photo-drafts.js', + BASE + 'static/photo-draft-inbox.js', BASE + 'static/search-defer.js', BASE + 'static/widgets.js', BASE + 'static/drafts.js', diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 74745c7..93bdcd6 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -41,12 +41,12 @@ FEATURE_SOURCES = { "today-timer": ( "static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-search-modal.js", "static/mobile-composer-viewport.js", "static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js", - "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js", + "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js", "static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js", "static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js", "static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js", "static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js", - "static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js", + "static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/mobile-update-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js", ), } CACHE_DECLARATION = re.compile( diff --git a/tests/e2e/test_mobile_photo_draft_inbox_release.py b/tests/e2e/test_mobile_photo_draft_inbox_release.py new file mode 100644 index 0000000..f77e9b0 --- /dev/null +++ b/tests/e2e/test_mobile_photo_draft_inbox_release.py @@ -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) diff --git a/tests/test_conversation_photo_drafts.py b/tests/test_conversation_photo_drafts.py index fc02fbd..ecf6f9b 100644 --- a/tests/test_conversation_photo_drafts.py +++ b/tests/test_conversation_photo_drafts.py @@ -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))}); diff --git a/tests/test_photo_draft_inbox.py b/tests/test_photo_draft_inbox.py new file mode 100644 index 0000000..d4a6984 --- /dev/null +++ b/tests/test_photo_draft_inbox.py @@ -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 '' 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 diff --git a/tests/test_search_reply_draft_store.py b/tests/test_search_reply_draft_store.py index 2491264..2c3de83 100644 --- a/tests/test_search_reply_draft_store.py +++ b/tests/test_search_reply_draft_store.py @@ -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))}); diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 03c3683..f0bcbc6 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -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",