feat: queue offline Search replies (Closes #959)
This commit is contained in:
parent
2e6c20d68d
commit
7fa43a9e50
10
README.md
10
README.md
|
|
@ -77,9 +77,13 @@ query, filters, results, and scroll position. If assignment succeeds but Later s
|
|||
remains recoverable in My Work and the dashboard reports the partial outcome instead of claiming success.
|
||||
Commentable mobile Search previews support camera capture and gallery selection for up to five ordered
|
||||
photos, including captions, crop/annotation/redaction review, and metadata-stripping re-encoding. Operators
|
||||
can send photo-only or text-plus-photo replies without leaving their Search pass. Each upload and the final
|
||||
comment use stable idempotency keys: a failed attempt keeps the draft, evidence order, and confirmed upload
|
||||
checkpoints, while **Send & next** advances only after Gitea confirms the comment.
|
||||
can queue photo-only, text-only, or mixed replies without leaving their Search pass, including while offline.
|
||||
Durable admission account-binds the exact issue/pull kind and stores image bytes in IndexedDB before the UI
|
||||
clears or advances. Reconnect revalidates the visible Search target through the narrower preview attachment
|
||||
and comment routes instead of assigned-work routes. Existing per-photo upload operation IDs and confirmed
|
||||
Markdown checkpoints survive handoff, so retry resumes from the first unfinished photo and the final comment
|
||||
posts once. **Send & next** advances only after durable admission; a storage failure keeps the current result,
|
||||
text, evidence, and Search position unchanged.
|
||||
Named mobile Search views preserve the query, type, status, and optional repository scope. They are
|
||||
bounded to 20 per confirmed account and synchronize through a revisioned SQLite collection, so another
|
||||
device can reopen the exact Search with one tap while stale writes surface a conflict instead of silently
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'search-reply', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker', 'issue-content']);
|
||||
|
||||
function messageAttachments(message) {
|
||||
const values = Array.isArray(message?.attachments) ? message.attachments :
|
||||
|
|
@ -19,6 +19,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
contentType: String(value.contentType || ''),
|
||||
stored: true,
|
||||
...(note ? { note } : {}),
|
||||
...(value.operationId ? { operationId:String(value.operationId).slice(0, 128) } : {}),
|
||||
...(value.confirmed?.markdown ? { confirmed:{ markdown:String(value.confirmed.markdown) } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -28,6 +30,8 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
filename: String(value.filename || ''),
|
||||
contentType: String(value.contentType || ''),
|
||||
...(note ? { note } : {}),
|
||||
...(value.operationId ? { operationId:String(value.operationId).slice(0, 128) } : {}),
|
||||
...(value.confirmed?.markdown ? { confirmed:{ markdown:String(value.confirmed.markdown) } } : {}),
|
||||
...(value.blob ? { blob:value.blob } : { data:String(value.data || '') }),
|
||||
};
|
||||
}
|
||||
|
|
@ -81,6 +85,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
|
||||
function enqueue(message, mirror = true) {
|
||||
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
|
||||
if (message.kind === 'search-reply' && !['issue', 'pull'].includes(message.targetKind)) {
|
||||
throw new Error('Choose an exact Search result before queueing a reply.');
|
||||
}
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
|
||||
const items = read();
|
||||
|
|
@ -128,8 +135,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
...(message.kind === 'search-reply' ? { targetKind:String(message.targetKind) } : {}),
|
||||
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
|
||||
...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && attachments.length ?
|
||||
...(['issue-comment', 'pull-comment', 'search-reply', 'update-reply', 'update-reply-read'].includes(message.kind) && attachments.length ?
|
||||
(attachments.length === 1 ? { attachment:attachmentMetadata(attachments[0]) } :
|
||||
{ attachments:attachments.map(attachmentMetadata) }) : {}),
|
||||
...(message.kind === 'pull-review' ? {
|
||||
|
|
@ -164,7 +172,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
const previousItems = read();
|
||||
const item = enqueue(message, false);
|
||||
const attachments = messageAttachments(message);
|
||||
if (attachments.length && ['update-reply', 'update-reply-read'].includes(message.kind) &&
|
||||
if (attachments.length && ['search-reply', 'update-reply', 'update-reply-read'].includes(message.kind) &&
|
||||
(!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
|
||||
write(read().filter(candidate => candidate.id !== item.id), false);
|
||||
throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.');
|
||||
|
|
@ -233,6 +241,10 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync,
|
|||
if (item.kind === 'pull-review') {
|
||||
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
||||
}
|
||||
if (item.kind === 'search-reply') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview/comments?kind=' + encodeURIComponent(item.targetKind);
|
||||
}
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -464,6 +464,13 @@ function createBackgroundIssueSync({
|
|||
item,
|
||||
);
|
||||
}
|
||||
if (item.kind === 'search-reply') {
|
||||
return authoredRequest(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) +
|
||||
'/preview/comments?kind=' + encodeURIComponent(item.targetKind),
|
||||
item,
|
||||
);
|
||||
}
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues',
|
||||
options: {
|
||||
|
|
@ -520,17 +527,20 @@ function createBackgroundIssueSync({
|
|||
async function uploadConversationAttachments(item, url) {
|
||||
let current = item;
|
||||
const attachments = conversationAttachments(current);
|
||||
const confirmedMarkdowns = attachments.map(value => String(value?.confirmed?.markdown || ''));
|
||||
const firstMissing = confirmedMarkdowns.findIndex(value => !value);
|
||||
const leadingConfirmed = confirmedMarkdowns.slice(0, firstMissing < 0 ? confirmedMarkdowns.length : firstMissing);
|
||||
const markdowns = Array.isArray(current.attachmentMarkdowns) ?
|
||||
current.attachmentMarkdowns.slice(0, attachments.length) :
|
||||
(current.attachmentMarkdown ? [current.attachmentMarkdown] : []);
|
||||
(current.attachmentMarkdown ? [current.attachmentMarkdown] : leadingConfirmed);
|
||||
for (let index = markdowns.length; index < attachments.length; index += 1) {
|
||||
const uploaded = await requestStage(current, url, {
|
||||
method:'POST',
|
||||
headers:{
|
||||
Accept:'application/json',
|
||||
'Idempotency-Key':stageOperationId(
|
||||
'Idempotency-Key':String(attachments[index]?.operationId || stageOperationId(
|
||||
current.operationId, attachments.length === 1 ? 'attachment' : 'attachment-' + index,
|
||||
),
|
||||
)).slice(0, 128),
|
||||
},
|
||||
body:attachmentMultipart(attachments[index]),
|
||||
});
|
||||
|
|
@ -695,6 +705,29 @@ function createBackgroundIssueSync({
|
|||
);
|
||||
}
|
||||
|
||||
async function deliverSearchReply(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
let current = item;
|
||||
let attachmentMarkdown = '';
|
||||
if (conversationAttachments(current).length) {
|
||||
const uploaded = await uploadConversationAttachments(current,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(current.number) +
|
||||
'/preview/attachments?kind=' + encodeURIComponent(current.targetKind));
|
||||
current = uploaded.current;
|
||||
attachmentMarkdown = uploaded.markdown;
|
||||
}
|
||||
const text = String(current.body || '').trim();
|
||||
return requestStage(current,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(current.number) +
|
||||
'/preview/comments?kind=' + encodeURIComponent(current.targetKind), {
|
||||
method:'POST',
|
||||
headers:{Accept:'application/json','Content-Type':'application/json',
|
||||
'Idempotency-Key':stageOperationId(current.operationId, 'comment')},
|
||||
body:JSON.stringify({body:attachmentMarkdown ?
|
||||
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text}),
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverUpdateScreenshotReply(item) {
|
||||
const uploaded = await uploadConversationAttachments(item,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/attachments');
|
||||
|
|
@ -714,6 +747,7 @@ function createBackgroundIssueSync({
|
|||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
|
||||
item.kind === 'search-reply' ? await deliverSearchReply(item) :
|
||||
item.kind === 'update-reply' && conversationAttachments(item).length ? await deliverUpdateScreenshotReply(item) :
|
||||
conversationAttachments(item).length && ['issue-comment', 'pull-comment'].includes(item.kind) ?
|
||||
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
||||
|
|
|
|||
|
|
@ -5134,6 +5134,17 @@
|
|||
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
||||
),
|
||||
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
|
||||
queueReply:async (item,body,operationId) => {
|
||||
searchReplyAttachmentTarget = item;
|
||||
const serialized = await searchReplyAttachmentController.serialize();
|
||||
const attachments = (Array.isArray(serialized) ? serialized : [serialized]).filter(Boolean);
|
||||
const admitted = await authoredOutbox.enqueueDurably({
|
||||
kind:'search-reply',targetKind:item.kind,repository:item.repository,number:item.number,
|
||||
body,operationId,...(attachments.length ? {attachments} : {}),
|
||||
});
|
||||
renderOutbox();
|
||||
return { id:admitted.item.id, queued:true };
|
||||
},
|
||||
prepareReply:(item,body) => {
|
||||
searchReplyAttachmentTarget = item;
|
||||
return searchReplyAttachmentController.prepareComment(item, body);
|
||||
|
|
|
|||
|
|
@ -84,14 +84,15 @@
|
|||
buttons.forEach(button => {
|
||||
button.disabled = replying || (!input.value.trim() && !preview.hasReplyAttachments?.());
|
||||
});
|
||||
if (replying) status.textContent = 'Sending reply…';
|
||||
if (replying) status.textContent = 'Saving reply for delivery…';
|
||||
else if (state.status === 'queued') status.textContent = 'Reply queued. It will send when connected.';
|
||||
else if (state.status === 'replied') status.textContent = 'Reply posted.';
|
||||
else if (state.status === 'reply-error') status.textContent =
|
||||
state.error?.message || 'Reply failed. Your draft is safe; retry when ready.';
|
||||
};
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
||||
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
||||
if (Array.isArray(session)) {
|
||||
getSession = session[0];
|
||||
loadMore = () => session[1].loadMore();
|
||||
|
|
@ -289,7 +290,9 @@
|
|||
},
|
||||
reply({ advance = false } = {}) {
|
||||
if (replyRequest) return replyRequest;
|
||||
if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.'));
|
||||
if (!current || (typeof postReply !== 'function' && typeof queueReply !== 'function')) {
|
||||
return Promise.reject(new Error('Replying is unavailable.'));
|
||||
}
|
||||
const body = api.replyDraft().trim();
|
||||
if (!body && !hasAttachments?.()) return Promise.reject(new Error('Write a reply or add a photo first.'));
|
||||
const item = { ...current };
|
||||
|
|
@ -300,20 +303,24 @@
|
|||
save(operationKey, operationId);
|
||||
}
|
||||
publish({ status:'replying', item:current, detail:current, conversation });
|
||||
replyRequest = Promise.resolve(
|
||||
typeof prepareReply === 'function' ? prepareReply(item, body) : body
|
||||
).then(preparedBody => {
|
||||
if (!String(preparedBody || '').trim()) throw new Error('Write a reply or add a photo first.');
|
||||
return postReply(item, preparedBody, operationId);
|
||||
}).then(async comment => {
|
||||
replyRequest = (typeof queueReply === 'function' ?
|
||||
Promise.resolve(queueReply(item, body, operationId)) :
|
||||
Promise.resolve(typeof prepareReply === 'function' ? prepareReply(item, body) : body)
|
||||
.then(preparedBody => {
|
||||
if (!String(preparedBody || '').trim()) throw new Error('Write a reply or add a photo first.');
|
||||
return postReply(item, preparedBody, operationId);
|
||||
})
|
||||
).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 };
|
||||
if (!comment?.queued) {
|
||||
const comments = [...(conversation?.comments || [])];
|
||||
if (!comments.some(candidate => candidate?.id === comment?.id)) comments.push(comment);
|
||||
conversation = { status:'ready', comments, olderPage:conversation?.olderPage ?? null };
|
||||
}
|
||||
save(replyKey(item, 'draft'), '');
|
||||
save(operationKey, '');
|
||||
clearAttachments?.();
|
||||
publish({ status:'replied', item:current, detail:current, conversation, result:comment });
|
||||
publish({ status:comment?.queued ? 'queued' : 'replied', item:current, detail:current, conversation, result:comment });
|
||||
return advance ? api.next().then(() => comment) : comment;
|
||||
}).catch(error => {
|
||||
publish({ status:'reply-error', item:current, detail:current, conversation, error });
|
||||
|
|
|
|||
|
|
@ -111,6 +111,46 @@ const photo=(filename,note)=>({{filename,contentType:'image/png',blob:new Blob([
|
|||
]
|
||||
|
||||
|
||||
def test_search_preview_reply_is_durably_bound_to_exact_kind_with_upload_checkpoints():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map(); const mirrors = [];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const outbox = createAuthoredOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy',
|
||||
backgroundSync:{{reconcile:async items=>mirrors.push(items),requestSync:async()=>{{}}}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const result=await outbox.enqueueDurably({{
|
||||
kind:'search-reply',targetKind:'pull',repository:'stackchain/web',number:42,
|
||||
body:'Ready offline.',operationId:'search-42',attachments:[{{
|
||||
filename:'proof.jpg',contentType:'image/jpeg',blob:new Blob(['proof']),note:'After repair',
|
||||
operationId:'photo-stage-1',confirmed:{{markdown:''}},
|
||||
}}],
|
||||
}});
|
||||
const metadata=outbox.list()[0]; const durable=mirrors[0][0];
|
||||
process.stdout.write(JSON.stringify({{
|
||||
item:result.item,metadata,
|
||||
durable:{{targetKind:durable.targetKind,operationId:durable.attachment.operationId,
|
||||
markdown:durable.attachment.confirmed?.markdown,hasBlob:durable.attachment.blob instanceof Blob}},
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["item"]["kind"] == "search-reply"
|
||||
assert output["item"]["targetKind"] == "pull"
|
||||
assert output["metadata"]["attachment"] == {
|
||||
"filename": "proof.jpg", "contentType": "image/jpeg", "stored": True,
|
||||
"note": "After repair", "operationId": "photo-stage-1",
|
||||
"confirmed": {"markdown": ""},
|
||||
}
|
||||
assert output["durable"] == {
|
||||
"targetKind": "pull", "operationId": "photo-stage-1",
|
||||
"markdown": "", "hasBlob": True,
|
||||
}
|
||||
|
||||
|
||||
def test_authored_outbox_persists_and_delivers_revision_checked_issue_content():
|
||||
script = f"""
|
||||
const createAuthoredOutbox = require({json.dumps(str(OUTBOX))});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,55 @@ def run_node(script: str) -> dict:
|
|||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_search_reply_reconnect_uses_preview_authorization_and_resumes_photo_checkpoints():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'search-42',operationId:'search-42',ownerLogin:'timmy',status:'queued',outboxLane:'authored',
|
||||
kind:'search-reply',targetKind:'pull',repository:'stackchain/web',number:42,body:'Ready offline.',attachments:[
|
||||
{{filename:'one.jpg',contentType:'image/jpeg',blob:new Blob(['one']),operationId:'photo-one',confirmed:{{markdown:''}}}},
|
||||
{{filename:'two.jpg',contentType:'image/jpeg',blob:new Blob(['two']),operationId:'photo-two'}},
|
||||
]}};
|
||||
const calls=[];let commentAttempts=0;
|
||||
const store={{claimNext:async()=>item?{{...item}}:null,update:async(_id,transform)=>{{item=transform(item);}},
|
||||
complete:async()=>{{item=null;}},release:async()=>{{item={{...item,status:'queued'}};}},
|
||||
fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async(url,options={{}})=>{{
|
||||
if(url==='api/v1/background-identity')return{{login:'timmy'}};
|
||||
const file=options.body instanceof FormData?options.body.get('file'):null;
|
||||
calls.push({{url,key:options.headers?.['Idempotency-Key'],filename:file?.name||'',
|
||||
body:file?null:JSON.parse(options.body)}});
|
||||
if(file)return{{markdown:''}};
|
||||
if(commentAttempts++===0){{const error=new Error('offline');error.status=503;throw error;}}
|
||||
return{{id:91,body:'Ready offline.'}};
|
||||
}};
|
||||
(async()=>{{const sync=createBackgroundIssueSync({{store,fetchJson}});let first='';
|
||||
try{{await sync.flush();}}catch(error){{first=error.message;}}
|
||||
const checkpoint={{...item}};const second=await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{calls,first,checkpoint,second}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["first"] == "offline"
|
||||
assert output["checkpoint"]["attachmentMarkdowns"] == [
|
||||
"", ""
|
||||
]
|
||||
uploads = [call for call in output["calls"] if call["filename"]]
|
||||
assert [(call["filename"], call["key"]) for call in uploads] == [
|
||||
("two.jpg", "photo-two")
|
||||
]
|
||||
comments = [call for call in output["calls"] if not call["filename"]]
|
||||
assert [call["url"] for call in comments] == [
|
||||
"api/v1/repos/stackchain/web/issues/42/preview/comments?kind=pull",
|
||||
"api/v1/repos/stackchain/web/issues/42/preview/comments?kind=pull",
|
||||
]
|
||||
assert [call["key"] for call in comments] == ["search-42:comment"] * 2
|
||||
assert comments[-1]["body"] == {
|
||||
"body": "Ready offline.\n\n\n\n"
|
||||
}
|
||||
assert output["second"]["confirmed"] == [{"id": 91, "body": "Ready offline."}]
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_matching_issue_once_with_original_idempotency_key():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
|
|
|||
|
|
@ -389,6 +389,33 @@ process.stdout.write(JSON.stringify({{
|
|||
}
|
||||
|
||||
|
||||
def test_search_preview_queue_and_next_advances_only_after_durable_admission():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async()=>{{
|
||||
const items=[1,2].map(number=>({{repository:'stackchain/api',number,kind:'issue'}}));
|
||||
const values=new Map();let admit;const navigated=[];const states=[];let cleared=0;
|
||||
const preview=createSearchPreview({{
|
||||
fetchJson:async item=>item,mutate:async()=>{{}},
|
||||
queueReply:(item,body,operationId)=>new Promise(resolve=>{{admit=()=>resolve({{id:operationId,queued:true}});}}),
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
|
||||
createOperationId:()=> 'offline-search-1',hasAttachments:()=>false,clearAttachments:()=>{{cleared+=1;}},
|
||||
getSession:()=>({{items,more:false}}),onNavigate:item=>navigated.push(item.number),onState:state=>states.push(state),
|
||||
}});
|
||||
await preview.open(items[0]);preview.saveReplyDraft('Queue safely.');
|
||||
const pending=preview.reply({{advance:true}});await Promise.resolve();const before=[...navigated];admit();await pending;
|
||||
process.stdout.write(JSON.stringify({{before,navigated,cleared,draft:values.size,
|
||||
queued:states.some(state=>state.status==='queued')}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": [], "navigated": [2], "cleared": 1, "draft": 0, "queued": True
|
||||
}
|
||||
|
||||
|
||||
def test_search_preview_reply_retry_keeps_draft_and_operation_until_server_confirmation():
|
||||
script = f"""
|
||||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
|
|
|
|||
|
|
@ -130,6 +130,9 @@ def test_mobile_search_preview_exposes_and_mounts_photo_evidence_controls():
|
|||
assert mount
|
||||
assert "maxFiles: 5" in mount.group(1)
|
||||
assert "'/preview/attachments?kind='" in mount.group(1)
|
||||
assert "queueReply:async (item,body,operationId)" in dashboard
|
||||
assert "kind:'search-reply',targetKind:item.kind" in dashboard
|
||||
assert "authoredOutbox.enqueueDurably" in dashboard
|
||||
assert ".search-preview-reply .conversation-photo-actions" in css
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user