From 5af45cda57bad8b2378982d5fa562e04e4092779 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 16 Aug 2026 10:07:46 +0000 Subject: [PATCH] feat: preserve Search photo reply drafts (Closes #957) --- frontend/dashboard.js | 51 ++++++++- frontend/index.html | 1 + frontend/issue-attachment.js | 41 ++++++-- frontend/private-data-registry.js | 1 + frontend/search-preview.js | 5 +- frontend/search-reply-draft-store.js | 121 ++++++++++++++++++++++ frontend/service-worker.js | 3 +- src/frontend_bundle.py | 2 +- tests/test_comment_next.py | 2 +- tests/test_dashboard_session_frontend.py | 5 + tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_mobile_start_day.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_private_data_registry.py | 1 + tests/test_private_device_data.py | 1 + tests/test_search_reply_draft_store.py | 118 +++++++++++++++++++++ tests/test_service_worker.py | 30 +++--- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 22 files changed, 363 insertions(+), 35 deletions(-) create mode 100644 frontend/search-reply-draft-store.js create mode 100644 tests/test_search_reply_draft_store.py diff --git a/frontend/dashboard.js b/frontend/dashboard.js index a30e61b..effbfae 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -495,6 +495,45 @@ qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.'; }); let searchReplyAttachmentTarget = null; + let searchReplyRestoreGeneration = 0; + let restoringSearchReplyPhotos = false; + const searchReplyDraftStore = 'indexedDB' in window ? createSearchReplyDraftStore({ + indexedDB:window.indexedDB, + getOwnerLogin:() => confirmedOwnerLogin, + }) : null; + const sameSearchReplyTarget = (left, right) => left && right && left.kind === right.kind && + left.repository === right.repository && Number(left.number) === Number(right.number); + async function persistSearchReplyPhotos() { + const target = searchReplyAttachmentTarget ? { ...searchReplyAttachmentTarget } : null; + if (!target || !searchReplyDraftStore || !searchReplyAttachmentController || restoringSearchReplyPhotos) return; + try { + const attachments = await searchReplyAttachmentController.serialize(); + await searchReplyDraftStore.save(target, attachments); + } catch (error) { + qs('#search-preview-reply-status').textContent = error?.message || + 'Photos could not be saved. They remain in this preview; retry before leaving.'; + throw error; + } + } + async function restoreSearchReplyPhotos(target) { + if (!searchReplyDraftStore || !target) return; + const generation = ++searchReplyRestoreGeneration; + try { + const attachments = await searchReplyDraftStore.load(target); + if (generation !== searchReplyRestoreGeneration || !sameSearchReplyTarget(target, searchReplyAttachmentTarget)) return; + if (attachments?.length) { + restoringSearchReplyPhotos = true; + try { searchReplyAttachmentController.restore(attachments); } + finally { restoringSearchReplyPhotos = false; } + updateSearchReplyButtons(searchReplyAttachmentController.state()); + qs('#search-preview-reply-status').textContent = 'Saved photo evidence restored.'; + } + } catch (error) { + if (generation === searchReplyRestoreGeneration) { + qs('#search-preview-reply-status').textContent = error?.message || 'Saved photo evidence could not be restored.'; + } + } + } const updateSearchReplyButtons = state => { const enabled = Boolean(state) || Boolean(qs('#search-preview-reply').value.trim()); qs('#send-search-preview-reply').disabled = !enabled; @@ -516,7 +555,11 @@ status: qs('#search-preview-reply-status'), readyMessage: 'Photo ready to send with this Search reply.', removedMessage: 'Photo removed. Your Search reply is unchanged.', - onChange: updateSearchReplyButtons, + onChange: state => { + updateSearchReplyButtons(state); + persistSearchReplyPhotos().catch(() => {}); + }, + onCheckpoint:() => persistSearchReplyPhotos(), editor: { document, edit: qs('#edit-search-reply-attachment'), @@ -5020,6 +5063,8 @@ renderSearchPreviewReply(state, null, searchPreview, document); if (state.status === 'loading') { + searchReplyAttachmentTarget = { ...state.item }; + restoreSearchReplyPhotos(searchReplyAttachmentTarget); searchVoiceReply.open(searchConversationVoiceTarget(state.item)); searchPreviewDetail = null; qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number; @@ -5093,6 +5138,10 @@ searchReplyAttachmentTarget = item; return searchReplyAttachmentController.prepareComment(item, body); }, + afterReply:item => { + if (!searchReplyDraftStore) return null; + return searchReplyDraftStore.remove(item); + }, hasAttachments:() => Boolean(searchReplyAttachmentController.state()), clearAttachments:() => { searchReplyAttachmentTarget = null; diff --git a/frontend/index.html b/frontend/index.html index 9ab7cd9..01be0f8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1440,6 +1440,7 @@ + diff --git a/frontend/issue-attachment.js b/frontend/issue-attachment.js index 63ce3b7..6408326 100644 --- a/frontend/issue-attachment.js +++ b/frontend/issue-attachment.js @@ -41,6 +41,7 @@ const maxFiles = options.maxFiles === MAX_FILES ? MAX_FILES : 1; const inspectPixels = options.inspectPixels === true; const optimizeSelectedImage = options.optimizeImage || optimizeImage; + const onCheckpoint = options.onCheckpoint; const createOperationId = options.createOperationId || (() => { if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) { return globalThis.crypto.randomUUID(); @@ -49,6 +50,13 @@ }); let selected = []; let selectionGeneration = 0; + let restoring = false; + + function changed() { + if (!restoring && typeof onCheckpoint === 'function') { + Promise.resolve().then(onCheckpoint).catch(() => { /* Caller surfaces durable-storage failures. */ }); + } + } function commitSelection(file) { if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) || @@ -60,6 +68,7 @@ else throw new Error(MAX_FILES_MESSAGE); } selected.push({file, note:'', confirmed:null, serialized:null, operationId:createOperationId()}); + changed(); return state(); } @@ -93,6 +102,7 @@ if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state(); selectionGeneration += 1; selected.splice(position, 1); + changed(); return state(); } @@ -103,6 +113,7 @@ from >= selected.length || to < 0 || to >= selected.length || from === to) return state(); const [item] = selected.splice(from, 1); selected.splice(to, 0, item); + changed(); return state(); } @@ -117,6 +128,7 @@ selected[position] = { ...selected[position], file, confirmed:null, serialized:null, operationId:createOperationId(), }; + changed(); return state(); } @@ -125,6 +137,7 @@ if (!Number.isInteger(position) || position < 0 || position >= selected.length) return state(); selected[position].note = normalizeNote(value); selected[position].serialized = null; + changed(); return state(); } @@ -134,15 +147,18 @@ } function restore(value) { - if (Array.isArray(value)) { + restoring = true; + try { + if (Array.isArray(value)) { + clear(); + if (value.length > maxFiles) throw new Error(MAX_FILES_MESSAGE); + value.forEach(attachment => restoreOne(attachment)); + return state(); + } clear(); - if (value.length > maxFiles) throw new Error(MAX_FILES_MESSAGE); - value.forEach(attachment => restoreOne(attachment)); + restoreOne(value); return state(); - } - clear(); - restoreOne(value); - return state(); + } finally { restoring = false; } } function restoreOne(value) { @@ -162,6 +178,10 @@ ...(blob ? { filename, contentType, blob } : { filename, contentType, data }), ...(item.note ? {note:item.note} : {}), }; + const operationId = String(value?.operationId || '').slice(0, 128); + if (operationId) item.operationId = operationId; + const markdown = String(value?.confirmed?.markdown || ''); + if (markdown) item.confirmed = { markdown }; } function state() { @@ -178,7 +198,11 @@ filename:item.file.name, contentType:item.file.type, blob:item.file.blob || item.file, ...(item.note ? {note:item.note} : {}), }; - return { ...item.serialized }; + return { + ...item.serialized, + operationId:item.operationId, + ...(item.confirmed?.markdown ? { confirmed:{ markdown:item.confirmed.markdown } } : {}), + }; } async function serialize() { @@ -206,6 +230,7 @@ evidence.confirmed = null; throw new Error('The server did not confirm the screenshot upload.'); } + await onCheckpoint?.(); } if (evidence.note) { markdown.push('**Screenshot ' + (markdown.length + 1) + ' — ' + escapeMarkdown(evidence.note) + '**\n\n' + diff --git a/frontend/private-data-registry.js b/frontend/private-data-registry.js index 1c86653..5b9ae8e 100644 --- a/frontend/private-data-registry.js +++ b/frontend/private-data-registry.js @@ -4,6 +4,7 @@ 'stackchain-offline-work-v2', 'stackchain-unfiled-captures-v1', 'stackchain-voice-transcripts-v1', + 'stackchain-search-reply-drafts-v1', ]); if (typeof module !== 'undefined' && module.exports) module.exports = databases; else root.stackchainPrivateDatabases = databases; diff --git a/frontend/search-preview.js b/frontend/search-preview.js index fd49d5b..f8e68bc 100644 --- a/frontend/search-preview.js +++ b/frontend/search-preview.js @@ -91,7 +91,7 @@ }; } })(typeof globalThis !== 'undefined' ? globalThis : this, function () { - return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, prepareReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) { + return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) { if (Array.isArray(session)) { getSession = session[0]; loadMore = () => session[1].loadMore(); @@ -305,7 +305,8 @@ ).then(preparedBody => { if (!String(preparedBody || '').trim()) throw new Error('Write a reply or add a photo first.'); return postReply(item, preparedBody, operationId); - }).then(comment => { + }).then(async comment => { + await afterReply?.(item); const comments = [...(conversation?.comments || [])]; if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment); conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null }; diff --git a/frontend/search-reply-draft-store.js b/frontend/search-reply-draft-store.js new file mode 100644 index 0000000..9fa78e1 --- /dev/null +++ b/frontend/search-reply-draft-store.js @@ -0,0 +1,121 @@ +(function (root, factory) { + const createSearchReplyDraftStore = factory(); + if (typeof module === 'object' && module.exports) module.exports = createSearchReplyDraftStore; + else root.createSearchReplyDraftStore = createSearchReplyDraftStore; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + const dbName = 'stackchain-search-reply-drafts-v1'; + const storeName = 'drafts'; + + function requestResult(request) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result ?? null); + request.onerror = () => reject(request.error || new Error('Search reply draft storage failed.')); + }); + } + + function createTransaction(indexedDB) { + if (!indexedDB) return null; + let databasePromise; + function database() { + if (!databasePromise) databasePromise = new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, 1); + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(storeName)) { + request.result.createObjectStore(storeName, { keyPath:'id' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('Search reply draft storage is unavailable.')); + }); + return databasePromise; + } + return async (operation, key, value) => { + const db = await database(); + const transaction = db.transaction(storeName, operation === 'get' ? 'readonly' : 'readwrite'); + const records = transaction.objectStore(storeName); + if (operation === 'put') return requestResult(records.put(value)); + if (operation === 'delete') return requestResult(records.delete(key)); + return requestResult(records.get(key)); + }; + } + + function normalizedTarget(target) { + const kind = String(target?.kind || ''); + const repository = String(target?.repository || ''); + const number = Number(target?.number || 0); + if (!['issue', 'pull'].includes(kind) || !repository || !Number.isInteger(number) || number < 1) { + throw new Error('Choose a Search result before saving photo evidence.'); + } + return { kind, repository, number }; + } + + function attachment(value) { + const blob = value?.blob; + const data = String(value?.data || ''); + if (!blob && !data) throw new Error('A saved Search photo is unavailable.'); + const note = String(value?.note || '').replace(/\s+/g, ' ').trim().slice(0, 240); + const operationId = String(value?.operationId || '').slice(0, 128); + const markdown = String(value?.confirmed?.markdown || ''); + return { + filename:String(value?.filename || ''), + contentType:String(value?.contentType || ''), + ...(blob ? { blob } : { data }), + ...(note ? { note } : {}), + ...(operationId ? { operationId } : {}), + ...(markdown ? { confirmed:{ markdown } } : {}), + }; + } + + return function createSearchReplyDraftStore({ + indexedDB = globalThis.indexedDB, + transaction, + getOwnerLogin = () => '', + } = {}) { + const transact = transaction || createTransaction(indexedDB); + + function identity(target) { + const ownerLogin = String(getOwnerLogin() || '').trim(); + if (!ownerLogin) throw new Error('Confirm your Gitea account before saving Search photos.'); + const normalized = normalizedTarget(target); + return { + ownerLogin, + target:normalized, + id:[ownerLogin, normalized.kind, normalized.repository, normalized.number] + .map(value => encodeURIComponent(String(value))).join(':'), + }; + } + + async function save(target, values) { + if (!transact) throw new Error('Photo draft storage needs IndexedDB. Your current photos are still here.'); + const { id, ownerLogin, 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, ...normalized, attachments:list }); + return list; + } + + async function load(target) { + if (!transact) return null; + const { id, ownerLogin, target:normalized } = identity(target); + const record = await transact('get', id); + if (record?.version !== 1 || record.ownerLogin !== ownerLogin || record.kind !== normalized.kind || + record.repository !== normalized.repository || Number(record.number) !== normalized.number || + !Array.isArray(record.attachments) || !record.attachments.length) return null; + return record.attachments.slice(0, 5).map(attachment); + } + + async function remove(target) { + if (!transact) return false; + const { id } = identity(target); + await transact('delete', id); + return true; + } + + return { save, load, remove }; + }; +}); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index b053094..11ec519 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,7 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v112'; +const CACHE = 'stackchain-dashboard-shell-v113'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; @@ -22,6 +22,7 @@ const SHELL = [ BASE + 'static/commands.js', BASE + 'static/saved-searches.js', BASE + 'static/search-preview.js', + BASE + 'static/search-reply-draft-store.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 d844bae..304919b 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -31,7 +31,7 @@ FEATURE_SOURCES = { "security-center": ("static/security-center.js",), "today-timer": ( "static/voice-transcript-store.js", "static/voice-conversation-capture.js", - "static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.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-recap.js", + "static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/search-reply-draft-store.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.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-recap.js", "static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.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", diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 83d0c92..1dba1d3 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v112" in worker + assert "stackchain-dashboard-shell-v113" in worker diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index 112f294..aa7e220 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -350,6 +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", ] assert result["remaining"] == ["gitea.preference"] assert result["replaced"] == [ @@ -376,6 +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", ] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["replaced"] == ["/dashboard/login?reason=session-expired"] @@ -507,6 +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", ] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}] @@ -530,6 +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", ] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}] @@ -626,6 +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", ] assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"] assert result["assigned"] == "/dashboard/login" diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index aa543fe..c55fe9b 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v112" in source + assert "stackchain-dashboard-shell-v113" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 9f21fc5..8b3d0c7 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v112" in worker + assert "stackchain-dashboard-shell-v113" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 2f51dc1..8ebe91f 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v112" in worker + assert "stackchain-dashboard-shell-v113" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index 7c74d8a..5737064 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "promptStorage:localStorage" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v112" in worker + assert "stackchain-dashboard-shell-v113" in worker assert ".device-setup-panel" in css assert ".device-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index 0ed9ea5..c6937c6 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -283,4 +283,4 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile assert ".mobile-start-day-finish { min-height:44px;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html assert "BASE + 'static/mobile-start-day.js'" in service_worker - assert "stackchain-dashboard-shell-v112" in service_worker + assert "stackchain-dashboard-shell-v113" in service_worker diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index caaa440..5d13f44 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history( def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v112" in source + assert "stackchain-dashboard-shell-v113" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_private_data_registry.py b/tests/test_private_data_registry.py index cb83fd2..cdb30c0 100644 --- a/tests/test_private_data_registry.py +++ b/tests/test_private_data_registry.py @@ -31,6 +31,7 @@ process.stdout.write(JSON.stringify(databases)); "stackchain-offline-work-v2", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", + "stackchain-search-reply-drafts-v1", } diff --git a/tests/test_private_device_data.py b/tests/test_private_device_data.py index 63ca37e..325a906 100644 --- a/tests/test_private_device_data.py +++ b/tests/test_private_device_data.py @@ -48,6 +48,7 @@ const clear = createPrivateDeviceDataPurger({{ "stackchain-offline-work-v2", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", + "stackchain-search-reply-drafts-v1", ] assert state["caches"] == ["stackchain-dashboard-shell-v37"] assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}] diff --git a/tests/test_search_reply_draft_store.py b/tests/test_search_reply_draft_store.py new file mode 100644 index 0000000..2491264 --- /dev/null +++ b/tests/test_search_reply_draft_store.py @@ -0,0 +1,118 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +STORE = ROOT / "frontend" / "search-reply-draft-store.js" +ATTACHMENT = ROOT / "frontend" / "issue-attachment.js" +INDEX = ROOT / "frontend" / "index.html" +DASHBOARD = ROOT / "frontend" / "dashboard.js" +REGISTRY = ROOT / "frontend" / "private-data-registry.js" +WORKER = ROOT / "frontend" / "service-worker.js" + + +def run_node(script: str) -> str: + return subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout + + +def test_search_reply_photo_drafts_restore_only_for_the_confirmed_account_and_exact_result(): + 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 target = {{kind:'issue', repository:'stackchain/dashboard', number:957}}; +const other = {{kind:'issue', repository:'stackchain/dashboard', number:958}}; +const photo = new Blob(['field-evidence'], {{type:'image/webp'}}); +(async()=>{{ + await store.save(target, [{{ + filename:'camera.webp', contentType:'image/webp', blob:photo, + note:'rack label', operationId:'upload-stable-1', + confirmed:{{markdown:'![camera](confirmed-url)'}}, + }}]); + const restored = await store.load(target); + const wrongTarget = await store.load(other); + login = 'alexander'; + const wrongAccount = await store.load(target); + process.stdout.write(JSON.stringify({{ + restored:{{...restored[0], blobText:await restored[0].blob.text(), blob:undefined}}, + wrongTarget, wrongAccount, recordCount:records.size, + }})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + output = json.loads(run_node(script)) + + assert output == { + "restored": { + "filename": "camera.webp", + "contentType": "image/webp", + "note": "rack label", + "operationId": "upload-stable-1", + "confirmed": {"markdown": "![camera](confirmed-url)"}, + "blobText": "field-evidence", + }, + "wrongTarget": None, + "wrongAccount": None, + "recordCount": 1, + } + + +def test_photo_bundle_checkpoint_restores_stable_uploads_without_repeating_them(): + script = f""" +const attachment = require({json.dumps(str(ATTACHMENT))}); +const photo = new Blob(['proof'], {{type:'image/png'}}); photo.name = 'proof.png'; +const uploads = []; +const first = attachment.create({{ + createOperationId:()=> 'stable-upload-957', + upload:async payload=>{{uploads.push(payload.operation_id);return {{markdown:'![proof](saved-url)'}};}}, +}}); +first.select(photo); +(async()=>{{ + await first.prepareComment({{repository:'stackchain/dashboard',number:957}}, 'Ready'); + const checkpoint = await first.serialize(); + const restored = attachment.create({{ + createOperationId:()=> 'must-not-replace-operation', + upload:async payload=>{{uploads.push(payload.operation_id);throw new Error('must not upload twice');}}, + }}); + restored.restore(checkpoint); + const comment = await restored.prepareComment({{repository:'stackchain/dashboard',number:957}}, 'Ready'); + process.stdout.write(JSON.stringify({{checkpoint:{{...checkpoint,blob:undefined}},comment,uploads}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + output = json.loads(run_node(script)) + + assert output == { + "checkpoint": { + "filename": "proof.png", + "contentType": "image/png", + "operationId": "stable-upload-957", + "confirmed": {"markdown": "![proof](saved-url)"}, + }, + "comment": "Ready\n\n![proof](saved-url)", + "uploads": ["stable-upload-957"], + } + + +def test_dashboard_checkpoints_restores_and_clears_search_photo_drafts_at_user_boundaries(): + html = INDEX.read_text() + dashboard = DASHBOARD.read_text() + registry = REGISTRY.read_text() + worker = WORKER.read_text() + + assert '' in html + assert html.index('static/search-reply-draft-store.js') < html.index('static/dashboard.js') + assert "'stackchain-search-reply-drafts-v1'" in registry + assert "BASE + 'static/search-reply-draft-store.js'" in worker + assert "createSearchReplyDraftStore({" in dashboard + assert "getOwnerLogin:() => confirmedOwnerLogin" in dashboard + assert "onCheckpoint:() => persistSearchReplyPhotos()" in dashboard + assert "searchReplyDraftStore.load(target)" in dashboard + assert "searchReplyDraftStore.remove(item)" in dashboard diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 45f2c9e..12bcd7c 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -155,7 +155,7 @@ async function dispatchPush(payload) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v112" in source + assert "stackchain-dashboard-shell-v113" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -164,7 +164,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v112" in source + assert "stackchain-dashboard-shell-v113" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/background-issue-sync.js'" in source @@ -173,7 +173,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v112" in source + assert "stackchain-dashboard-shell-v113" in source assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-attachment.js'" in source @@ -181,14 +181,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v112" in source + assert "stackchain-dashboard-shell-v113" in source assert "BASE + 'static/dashboard.js'" in source def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v112" in source + assert "stackchain-dashboard-shell-v113" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -196,7 +196,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v112" in source + assert "stackchain-dashboard-shell-v113" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -204,7 +204,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v112" in source + assert "stackchain-dashboard-shell-v113" in source assert "BASE + 'static/issue-sheet.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -214,14 +214,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v112" in source + assert "stackchain-dashboard-shell-v113" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v112" in source + assert "stackchain-dashboard-shell-v113" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -230,21 +230,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v112" in source + assert "stackchain-dashboard-shell-v113" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v112" in source + assert "stackchain-dashboard-shell-v113" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v112" in source + assert "stackchain-dashboard-shell-v113" in source assert "BASE + 'static/update-ownership.js'" in source @@ -430,6 +430,7 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl "stackchain-offline-work-v2", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", + "stackchain-search-reply-drafts-v1", ] assert result["state"]["deleted"] == ["stackchain-dashboard-old"] assert result["state"]["clientMessages"] == [ @@ -523,6 +524,7 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion(): "stackchain-offline-work-v2", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", + "stackchain-search-reply-drafts-v1", ] assert result["replies"] == [{"ok": True}] @@ -910,7 +912,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain(): def test_queue_today_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v112" in source + assert "stackchain-dashboard-shell-v113" in source assert "BASE + 'static/queue-today.js'" in source @@ -939,6 +941,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/commands.js", "/dashboard/static/saved-searches.js", "/dashboard/static/search-preview.js", + "/dashboard/static/search-reply-draft-store.js", "/dashboard/static/search-defer.js", "/dashboard/static/widgets.js", "/dashboard/static/drafts.js", @@ -1126,6 +1129,7 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she "stackchain-offline-work-v2", "stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1", + "stackchain-search-reply-drafts-v1", ] assert result["state"]["deleted"] == ["stackchain-dashboard-old"] assert "private cached dashboard" not in result["body"] diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index fc3f3fe..10293e4 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v112';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v113';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 597da58..4fa3387 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}}); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v112" in source + assert "stackchain-dashboard-shell-v113" in source assert "BASE + 'static/today-sync.js'" in source