feat: attach photos from Search preview replies (Closes #955)
This commit is contained in:
parent
053f32c428
commit
d3a57f222e
|
|
@ -75,6 +75,11 @@ Today or interrupting active work. Cancel and browser Back preserve the Search p
|
|||
confirmation claims only when needed, syncs the Later plan across devices, and returns to the preserved
|
||||
query, filters, results, and scroll position. If assignment succeeds but Later storage fails, the issue
|
||||
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.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -646,6 +646,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.search-preview-reply-actions button { min-height:44px; }
|
||||
.search-preview-reply .voice-conversation-controls button,
|
||||
.search-preview-reply .voice-conversation-review-actions button { min-height:44px; }
|
||||
.search-preview-reply .conversation-photo-actions { max-width:100%; }
|
||||
.search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; }
|
||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; }
|
||||
|
|
|
|||
|
|
@ -494,6 +494,61 @@
|
|||
window.location.hash = '#/my-work/agenda';
|
||||
qs('#my-work-action-status').textContent = 'Overdue sweep cancelled. No remaining deadline was changed.';
|
||||
});
|
||||
let searchReplyAttachmentTarget = null;
|
||||
const updateSearchReplyButtons = state => {
|
||||
const enabled = Boolean(state) || Boolean(qs('#search-preview-reply').value.trim());
|
||||
qs('#send-search-preview-reply').disabled = !enabled;
|
||||
qs('#send-search-preview-reply-next').disabled = !enabled;
|
||||
};
|
||||
const searchReplyAttachmentController = issueAttachment.mount({
|
||||
maxFiles: 5,
|
||||
input: qs('#search-reply-attachment'),
|
||||
inputs: [qs('#take-search-reply-photo'), qs('#search-reply-attachment')],
|
||||
preview: qs('#search-reply-attachment-preview'),
|
||||
image: qs('#search-reply-attachment-image'),
|
||||
meta: qs('#search-reply-attachment-meta'),
|
||||
remove: qs('#remove-search-reply-attachment'),
|
||||
tray: qs('#search-reply-attachment-tray'),
|
||||
earlier: qs('#move-search-reply-attachment-earlier'),
|
||||
later: qs('#move-search-reply-attachment-later'),
|
||||
note: qs('#search-reply-attachment-note'),
|
||||
noteLabel: qs('#search-reply-attachment-note-label'),
|
||||
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,
|
||||
editor: {
|
||||
document,
|
||||
edit: qs('#edit-search-reply-attachment'),
|
||||
dialog: qs('#issue-evidence-editor'),
|
||||
canvas: qs('#issue-evidence-editor-canvas'),
|
||||
exportCanvas: qs('#issue-evidence-editor-export'),
|
||||
crop: qs('#crop-issue-evidence'), redact: qs('#redact-issue-evidence'),
|
||||
highlight: qs('#highlight-issue-evidence'), arrow: qs('#arrow-issue-evidence'),
|
||||
undo: qs('#undo-issue-evidence-edit'), reset: qs('#reset-issue-evidence-edit'),
|
||||
cancel: qs('#cancel-issue-evidence-edit'), apply: qs('#apply-issue-evidence-edit'),
|
||||
status: qs('#issue-evidence-editor-status'),
|
||||
appliedMessage: 'Edited photo flattened and ready to send.',
|
||||
},
|
||||
createObjectURL: file => URL.createObjectURL(file),
|
||||
revokeObjectURL: url => URL.revokeObjectURL(url),
|
||||
upload: payload => {
|
||||
const target = searchReplyAttachmentTarget;
|
||||
if (!target || target.repository !== payload.repository || Number(target.number) !== Number(payload.number)) {
|
||||
return Promise.reject(new Error('Search result changed. Reopen it before sending this photo.'));
|
||||
}
|
||||
const repository = payload.repository.split('/').map(encodeURIComponent).join('/');
|
||||
return fetchReviewJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(payload.number) +
|
||||
'/preview/attachments?kind=' + encodeURIComponent(target.kind),
|
||||
{
|
||||
method:'POST',
|
||||
headers:{Accept:'application/json','Idempotency-Key':payload.operation_id},
|
||||
body:issueAttachment.multipart(payload),
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
const issueAttachmentController = issueAttachment.mount({
|
||||
maxFiles: 5,
|
||||
input: qs('#issue-attachment'),
|
||||
|
|
@ -5034,6 +5089,15 @@
|
|||
{ method:'PATCH', headers:{ Accept:'application/json' } }
|
||||
),
|
||||
...searchPreviewReplyOptions(fetchReviewJson, localStorage, globalThis.crypto),
|
||||
prepareReply:(item,body) => {
|
||||
searchReplyAttachmentTarget = item;
|
||||
return searchReplyAttachmentController.prepareComment(item, body);
|
||||
},
|
||||
hasAttachments:() => Boolean(searchReplyAttachmentController.state()),
|
||||
clearAttachments:() => {
|
||||
searchReplyAttachmentTarget = null;
|
||||
searchReplyAttachmentController.clear();
|
||||
},
|
||||
share: url => createWorkRoute.share(url, navigator, navigator.clipboard),
|
||||
session:[()=>commandSearchState, commandSearch, item=>taskOverlayHistory.update({preview:item})],
|
||||
onState: renderSearchPreview,
|
||||
|
|
|
|||
|
|
@ -562,6 +562,27 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="issue-attachment-controls conversation-photo-actions">
|
||||
<label class="issue-attachment-trigger" for="take-search-reply-photo">Take photo</label>
|
||||
<input class="visually-hidden" id="take-search-reply-photo" type="file" accept="image/*" capture="environment" />
|
||||
<label class="issue-attachment-trigger" for="search-reply-attachment">Choose existing</label>
|
||||
<input class="visually-hidden" id="search-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple />
|
||||
</div>
|
||||
<div class="issue-attachment-preview conversation-evidence-review" id="search-reply-attachment-preview" hidden>
|
||||
<img id="search-reply-attachment-image" alt="Selected reply photo preview" />
|
||||
<span class="small" id="search-reply-attachment-meta"></span>
|
||||
<div class="issue-evidence-tray" id="search-reply-attachment-tray" role="toolbar" aria-label="Search reply photos" hidden></div>
|
||||
<label class="issue-evidence-note" for="search-reply-attachment-note">
|
||||
<span id="search-reply-attachment-note-label">Photo caption (optional)</span>
|
||||
<textarea id="search-reply-attachment-note" maxlength="240" rows="2" disabled placeholder="What does this photo show?"></textarea>
|
||||
</label>
|
||||
<div class="issue-evidence-review-actions" aria-label="Reorder selected photo">
|
||||
<button id="move-search-reply-attachment-earlier" type="button">Move earlier</button>
|
||||
<button id="edit-search-reply-attachment" type="button">Edit photo</button>
|
||||
<button id="remove-search-reply-attachment" type="button">Remove selected</button>
|
||||
<button id="move-search-reply-attachment-later" type="button">Move later</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="search-preview-reply-status" class="small" aria-live="polite"></div>
|
||||
<div class="search-preview-reply-actions">
|
||||
<button id="send-search-preview-reply" type="button">Send</button>
|
||||
|
|
|
|||
|
|
@ -255,6 +255,7 @@
|
|||
input.disabled = false;
|
||||
});
|
||||
clearSelection();
|
||||
options.onChange?.(controller.state());
|
||||
}
|
||||
|
||||
function showPreview(file, optimized) {
|
||||
|
|
@ -311,6 +312,7 @@
|
|||
options.status.textContent = values.length + ' screenshots ready to file in this order.';
|
||||
}
|
||||
}
|
||||
options.onChange?.(controller.state());
|
||||
}).catch(error => {
|
||||
if (sequence === selectionSequence) {
|
||||
options.status.textContent = error.message;
|
||||
|
|
@ -343,6 +345,7 @@
|
|||
options.meta.textContent = values.length + ' screenshots ready · latest: ' + latest.filename;
|
||||
}
|
||||
options.status.textContent = options.removedMessage || 'Latest screenshot removed. Your text is unchanged.';
|
||||
options.onChange?.(controller.state());
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -81,7 +81,9 @@
|
|||
}
|
||||
input.value = preview.replyDraft();
|
||||
const replying = state.status === 'replying';
|
||||
buttons.forEach(button => { button.disabled = replying || !input.value.trim(); });
|
||||
buttons.forEach(button => {
|
||||
button.disabled = replying || (!input.value.trim() && !preview.hasReplyAttachments?.());
|
||||
});
|
||||
if (replying) status.textContent = 'Sending reply…';
|
||||
else if (state.status === 'replied') status.textContent = 'Reply posted.';
|
||||
else if (state.status === 'reply-error') status.textContent =
|
||||
|
|
@ -89,7 +91,7 @@
|
|||
};
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
||||
return function createSearchPreview({ fetchJson, fetchConversation, mutate, share, postReply, prepareReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, navigationRoot, onState }) {
|
||||
if (Array.isArray(session)) {
|
||||
getSession = session[0];
|
||||
loadMore = () => session[1].loadMore();
|
||||
|
|
@ -201,7 +203,11 @@
|
|||
}
|
||||
|
||||
const api = {
|
||||
hasReplyAttachments() {
|
||||
return Boolean(hasAttachments?.());
|
||||
},
|
||||
open(item) {
|
||||
if (current && !sameItem(current, item) && hasAttachments?.()) clearAttachments?.();
|
||||
generation += 1;
|
||||
const requestGeneration = generation;
|
||||
current = { ...item };
|
||||
|
|
@ -225,6 +231,7 @@
|
|||
});
|
||||
},
|
||||
close() {
|
||||
clearAttachments?.();
|
||||
generation += 1;
|
||||
current = null;
|
||||
onState({ status: 'closed' });
|
||||
|
|
@ -284,7 +291,7 @@
|
|||
if (replyRequest) return replyRequest;
|
||||
if (!current || typeof postReply !== 'function') return Promise.reject(new Error('Replying is unavailable.'));
|
||||
const body = api.replyDraft().trim();
|
||||
if (!body) return Promise.reject(new Error('Write a reply first.'));
|
||||
if (!body && !hasAttachments?.()) return Promise.reject(new Error('Write a reply or add a photo first.'));
|
||||
const item = { ...current };
|
||||
const operationKey = replyKey(item, 'operation');
|
||||
let operationId = stored(operationKey);
|
||||
|
|
@ -293,12 +300,18 @@
|
|||
save(operationKey, operationId);
|
||||
}
|
||||
publish({ status:'replying', item:current, detail:current, conversation });
|
||||
replyRequest = postReply(item, body, operationId).then(comment => {
|
||||
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(comment => {
|
||||
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 });
|
||||
return advance ? api.next().then(() => comment) : comment;
|
||||
}).catch(error => {
|
||||
|
|
|
|||
|
|
@ -1364,6 +1364,32 @@ async def upload_assigned_issue_attachment(
|
|||
return {"name": name, "url": url, "size": size}
|
||||
|
||||
|
||||
async def upload_preview_attachment(
|
||||
repository: str,
|
||||
number: int,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
content: bytes,
|
||||
) -> dict:
|
||||
"""Upload evidence after the caller verifies an exact Search preview target."""
|
||||
response = await _get_client().post(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/assets",
|
||||
headers=_auth(),
|
||||
params={"name": filename},
|
||||
files={"attachment": (filename, content, content_type)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
attachment = response.json()
|
||||
if not isinstance(attachment, dict):
|
||||
raise ValueError("Gitea attachment response was not an object")
|
||||
name = attachment.get("name")
|
||||
url = _safe_web_url(attachment.get("browser_download_url"))
|
||||
size = attachment.get("size")
|
||||
if not isinstance(name, str) or not name or not url or not isinstance(size, int):
|
||||
raise ValueError("Gitea did not confirm the attachment")
|
||||
return {"name": name, "url": url, "size": size}
|
||||
|
||||
|
||||
async def upload_assigned_pull_attachment(
|
||||
repository: str,
|
||||
number: int,
|
||||
|
|
|
|||
65
src/main.py
65
src/main.py
|
|
@ -3136,6 +3136,71 @@ async def comment_on_global_search_preview(
|
|||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/api/v1/repos/{owner}/{repo}/issues/{number}/preview/attachments",
|
||||
status_code=201,
|
||||
)
|
||||
async def attach_to_global_search_preview(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
kind: Literal["issue", "pull"] = Query(),
|
||||
idempotency_key: str | None = Header(default=None, max_length=128),
|
||||
) -> JSONResponse:
|
||||
repository = f"{owner}/{repo}"
|
||||
try:
|
||||
form = await request.form()
|
||||
uploaded = form.get("file")
|
||||
if not isinstance(uploaded, UploadFile):
|
||||
raise ValueError("screenshot file is required")
|
||||
filename = str(uploaded.filename or "")
|
||||
content_type = str(uploaded.content_type or "")
|
||||
content = _validate_binary_attachment(filename, content_type, await uploaded.read())
|
||||
except (ValueError, ValidationError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
async def upload_attachment():
|
||||
preview = await gitea_proxy.work_preview(repository, kind, number)
|
||||
if (
|
||||
preview.get("repository") != repository
|
||||
or preview.get("kind") != kind
|
||||
or preview.get("number") != number
|
||||
or preview.get("commentable") is not True
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Search result not found")
|
||||
result = await gitea_proxy.upload_preview_attachment(
|
||||
repository, number, filename, content_type, content
|
||||
)
|
||||
safe_name = (
|
||||
result["name"].replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
|
||||
.replace("\r", " ").replace("\n", " ")
|
||||
)
|
||||
safe_url = result["url"].replace("<", "%3C").replace(">", "%3E")
|
||||
result["markdown"] = f""
|
||||
return result
|
||||
|
||||
try:
|
||||
result = await _run_idempotent_authored_action(
|
||||
upload_attachment(),
|
||||
idempotency_key=idempotency_key,
|
||||
fingerprint=(
|
||||
"search-preview-attachment", repository, kind, number, filename,
|
||||
content_type, hashlib.sha256(content).hexdigest(),
|
||||
),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The photo could not be uploaded. Your reply is safe; please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result, status_code=201)
|
||||
|
||||
|
||||
@app.get("/api/v1/work-route")
|
||||
async def resolve_work_route(
|
||||
kind: Literal["issue", "filed", "pull", "review", "update"] = Query(),
|
||||
|
|
|
|||
|
|
@ -325,6 +325,70 @@ async def test_global_search_preview_reply_rejects_a_mismatched_target_before_co
|
|||
assert comments == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_preview_attachment_is_idempotent_for_exact_visible_target(monkeypatch):
|
||||
main._idempotency_ledger.clear()
|
||||
previews = []
|
||||
uploads = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
previews.append((repository, kind, number))
|
||||
return {"repository": repository, "kind": kind, "number": number, "commentable": True}
|
||||
|
||||
async def upload(repository, number, filename, content_type, content):
|
||||
uploads.append((repository, number, filename, content_type, content))
|
||||
return {"name": filename, "url": "https://forge.example/evidence/photo.png", "size": len(content)}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_preview_attachment", upload)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "search-photo-pull-42"}
|
||||
image = b"\x89PNG\r\n\x1a\nsafe-pixels"
|
||||
files = {"file": ("photo.png", image, "image/png")}
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
first = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/attachments?kind=pull",
|
||||
files=files,
|
||||
headers=headers,
|
||||
)
|
||||
replay = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/attachments?kind=pull",
|
||||
files=files,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert first.status_code == replay.status_code == 201
|
||||
assert replay.json() == first.json()
|
||||
assert first.json()["markdown"] == ""
|
||||
assert previews == [("stackchain/api", "pull", 42)]
|
||||
assert uploads == [("stackchain/api", 42, "photo.png", "image/png", image)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_preview_attachment_rejects_kind_mismatch_before_upload(monkeypatch):
|
||||
main._idempotency_ledger.clear()
|
||||
uploads = []
|
||||
|
||||
async def preview(repository, kind, number):
|
||||
return {"repository": repository, "kind": "issue", "number": number}
|
||||
|
||||
async def upload(*args):
|
||||
uploads.append(args)
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||
monkeypatch.setattr(main.gitea_proxy, "upload_preview_attachment", upload)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/repos/stackchain/api/issues/42/preview/attachments?kind=pull",
|
||||
files={"file": ("photo.png", b"\x89PNG\r\n\x1a\nsafe-pixels", "image/png")},
|
||||
headers={"Idempotency-Key": "search-photo-mismatch-42"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert uploads == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_global_search_queries_issues_and_pulls_and_skips_unsafe_results():
|
||||
requests = []
|
||||
|
|
|
|||
|
|
@ -113,6 +113,60 @@ def test_mobile_conversation_composers_accept_five_ordered_photos():
|
|||
assert "maxFiles: 5" in mount.group(1), controller
|
||||
|
||||
|
||||
def test_mobile_search_preview_exposes_and_mounts_photo_evidence_controls():
|
||||
html = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'for="take-search-reply-photo">Take photo</label>' in html
|
||||
assert 'id="take-search-reply-photo" type="file" accept="image/*" capture="environment"' in html
|
||||
assert 'for="search-reply-attachment">Choose existing</label>' in html
|
||||
assert 'id="search-reply-attachment" type="file" accept="image/png,image/jpeg,image/webp" multiple' in html
|
||||
mount = re.search(
|
||||
r"const searchReplyAttachmentController = issueAttachment\.mount\(\{(.*?)\n \}\);",
|
||||
dashboard,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert mount
|
||||
assert "maxFiles: 5" in mount.group(1)
|
||||
assert "'/preview/attachments?kind='" in mount.group(1)
|
||||
assert ".search-preview-reply .conversation-photo-actions" in css
|
||||
|
||||
|
||||
def test_search_preview_can_send_photo_only_and_advances_after_confirmed_comment():
|
||||
search_preview = Path(__file__).parents[1] / "frontend" / "search-preview.js"
|
||||
script = f"""
|
||||
const create = require({json.dumps(str(search_preview))});
|
||||
const posted=[]; const prepared=[]; let cleared=0; let navigated=[]; let attached=true;
|
||||
const item={{kind:'issue',repository:'stackchain/api',number:42}};
|
||||
const next={{kind:'pull',repository:'stackchain/web',number:9}};
|
||||
const api=create({{
|
||||
fetchJson:async value=>({{...value,commentable:true}}),
|
||||
postReply:async (target,body,key)=>{{posted.push([target.number,body,key]);return {{id:91,body}};}},
|
||||
prepareReply:async (target,body)=>{{prepared.push([target.number,body]);return '';}},
|
||||
hasAttachments:()=>attached,
|
||||
clearAttachments:()=>{{cleared += 1;attached=false;}},
|
||||
storage:{{getItem:()=>'',setItem:()=>{{}},removeItem:()=>{{}}}},
|
||||
createOperationId:()=> 'reply-photo-42',
|
||||
getSession:()=>({{items:[item,next],more:false}}),
|
||||
onNavigate:value=>navigated.push(value.number),
|
||||
onState:()=>{{}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await api.open(item);
|
||||
await api.reply({{advance:true}});
|
||||
process.stdout.write(JSON.stringify({{posted,prepared,cleared,navigated}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
output = json.loads(run_node(script))
|
||||
assert output == {
|
||||
"posted": [[42, "", "reply-photo-42"]],
|
||||
"prepared": [[42, ""]],
|
||||
"cleared": 1,
|
||||
"navigated": [9],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_conversation_composers_review_reorder_remove_and_caption_every_photo():
|
||||
html = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user