Compare commits
No commits in common. "ec339d242dba53346ec72fd178ff015afeadbf85" and "535658185addb0718651a8b339574bef41e359e4" have entirely different histories.
ec339d242d
...
535658185a
|
|
@ -35,7 +35,6 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||||
transaction.onerror = () => reject(transaction.error);
|
transaction.onerror = () => reject(transaction.error);
|
||||||
transaction.onabort = () => reject(transaction.error || new Error('Issue outbox transaction aborted'));
|
transaction.onabort = () => reject(transaction.error || new Error('Issue outbox transaction aborted'));
|
||||||
Promise.resolve(work({
|
Promise.resolve(work({
|
||||||
get: id => requested(objectStore.get(id)),
|
|
||||||
getAll: () => requested(objectStore.getAll()),
|
getAll: () => requested(objectStore.getAll()),
|
||||||
put: value => requested(objectStore.put(value)),
|
put: value => requested(objectStore.put(value)),
|
||||||
delete: id => requested(objectStore.delete(id)),
|
delete: id => requested(objectStore.delete(id)),
|
||||||
|
|
@ -188,7 +187,6 @@ function createIssueSyncStore({ transaction, indexedDB = globalThis.indexedDB, n
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get: id => transact(records => records.get(id)),
|
|
||||||
reconcile,
|
reconcile,
|
||||||
upsert,
|
upsert,
|
||||||
update,
|
update,
|
||||||
|
|
@ -531,7 +529,6 @@ function createBackgroundIssueSync({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
flush, send, purge, resume,
|
flush, send, purge, resume,
|
||||||
get: id => store.get(id),
|
|
||||||
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
||||||
snapshot: () => store.snapshot(),
|
snapshot: () => store.snapshot(),
|
||||||
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
||||||
|
|
|
||||||
|
|
@ -1710,26 +1710,16 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
list.querySelectorAll('.draft-edit').forEach(button => {
|
list.querySelectorAll('.draft-edit').forEach(button => {
|
||||||
button.addEventListener('click', async () => {
|
button.addEventListener('click', () => {
|
||||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||||
const queued = issueOutbox.list().find(candidate => candidate.id === item?.outbox_id);
|
const queued = issueOutbox.list().find(candidate => candidate.id === item?.outbox_id);
|
||||||
if (!queued) return;
|
if (!queued) return;
|
||||||
button.disabled = true;
|
editingOutboxId = queued.id;
|
||||||
try {
|
issueCapture.saveDraft(queued);
|
||||||
const hydrated = await issueOutbox.hydrateForEdit(queued.id);
|
|
||||||
if (!hydrated) return;
|
|
||||||
editingOutboxId = hydrated.id;
|
|
||||||
issueCapture.saveDraft(hydrated);
|
|
||||||
openCreateIssueSheet();
|
openCreateIssueSheet();
|
||||||
if (hydrated.attachment) createIssueAttachmentController.restore(hydrated.attachment);
|
if (queued.attachment) createIssueAttachmentController.restore(queued.attachment);
|
||||||
else createIssueAttachmentController.clear();
|
else createIssueAttachmentController.clear();
|
||||||
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
|
qs('#create-issue-status').textContent = 'Edit this queued issue, then send again.';
|
||||||
} catch (error) {
|
|
||||||
qs('#my-work-action-status').textContent = String(error?.message ||
|
|
||||||
'The saved screenshot could not be loaded. Retry before editing this issue.');
|
|
||||||
} finally {
|
|
||||||
button.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
list.querySelectorAll('.draft-send').forEach(button => {
|
list.querySelectorAll('.draft-send').forEach(button => {
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,6 @@
|
||||||
const contentType = String(value?.contentType || '');
|
const contentType = String(value?.contentType || '');
|
||||||
const filename = String(value?.filename || '');
|
const filename = String(value?.filename || '');
|
||||||
const data = String(value?.data || '');
|
const data = String(value?.data || '');
|
||||||
if (!data) {
|
|
||||||
clear();
|
|
||||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
|
||||||
}
|
|
||||||
const padding = (data.match(/=*$/) || [''])[0].length;
|
const padding = (data.match(/=*$/) || [''])[0].length;
|
||||||
const size = Math.max(1, Math.floor(data.length * 3 / 4) - padding);
|
const size = Math.max(1, Math.floor(data.length * 3 / 4) - padding);
|
||||||
select({ name: filename, type: contentType, size });
|
select({ name: filename, type: contentType, size });
|
||||||
|
|
|
||||||
|
|
@ -100,21 +100,6 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateForEdit(id) {
|
|
||||||
const item = read().find(candidate => candidate.id === id);
|
|
||||||
if (!item) return null;
|
|
||||||
if (!item.attachment?.stored || item.attachment.data) return { ...item };
|
|
||||||
if (!backgroundSync?.get) {
|
|
||||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
|
||||||
}
|
|
||||||
const durable = await backgroundSync.get(id);
|
|
||||||
const attachment = captureAttachment(durable?.attachment);
|
|
||||||
if (!attachment?.data || durable?.operationId !== item.operationId) {
|
|
||||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
|
||||||
}
|
|
||||||
return { ...item, attachment };
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareUpdate(id, draft) {
|
function prepareUpdate(id, draft) {
|
||||||
let updated = null;
|
let updated = null;
|
||||||
const items = read().map(item => {
|
const items = read().map(item => {
|
||||||
|
|
@ -403,7 +388,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enqueue, enqueueDurably, hydrateForEdit, update, updateDurably, discard, flush, retry, reconcileBackground,
|
enqueue, enqueueDurably, update, updateDurably, discard, flush, retry, reconcileBackground,
|
||||||
pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })),
|
pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -383,31 +383,6 @@ const transaction=work=>{{const run=tail.then(()=>work({{
|
||||||
assert output[1]["attachment"]["data"] == "second-image-bytes"
|
assert output[1]["attachment"]["data"] == "second-image-bytes"
|
||||||
|
|
||||||
|
|
||||||
def test_issue_sync_store_hydrates_one_capture_by_key_without_scanning_all_records():
|
|
||||||
script = f"""
|
|
||||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
|
||||||
const records=new Map([
|
|
||||||
['first',{{id:'first',attachment:{{filename:'one.png',contentType:'image/png',data:'first-bytes'}}}}],
|
|
||||||
['second',{{id:'second',attachment:{{filename:'two.png',contentType:'image/png',data:'second-bytes'}}}}],
|
|
||||||
]);
|
|
||||||
const calls=[];
|
|
||||||
const transaction=work=>work({{
|
|
||||||
get:async id=>{{calls.push(['get',id]);return records.get(id);}},
|
|
||||||
getAll:async()=>{{calls.push(['getAll']);return [...records.values()];}},
|
|
||||||
put:async()=>{{}},delete:async()=>{{}},
|
|
||||||
}});
|
|
||||||
(async()=>{{
|
|
||||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
|
||||||
const hydrated=await store.get('second');
|
|
||||||
process.stdout.write(JSON.stringify({{hydrated,calls}}));
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
output = run_node(script)
|
|
||||||
|
|
||||||
assert output["hydrated"]["attachment"]["data"] == "second-bytes"
|
|
||||||
assert output["calls"] == [["get", "second"]]
|
|
||||||
|
|
||||||
|
|
||||||
def test_stale_foreground_upsert_preserves_confirmed_attachment_delivery_stages():
|
def test_stale_foreground_upsert_preserves_confirmed_attachment_delivery_stages():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||||
|
|
|
||||||
|
|
@ -119,24 +119,6 @@ controller.restore({{filename:'saved.png',contentType:'image/png',data:'iVBORw0K
|
||||||
assert output["removed"] is None
|
assert output["removed"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_metadata_only_attachment_cannot_render_as_an_empty_screenshot():
|
|
||||||
script = f"""
|
|
||||||
const attachment=require({json.dumps(str(ATTACHMENT))});
|
|
||||||
const controller=attachment.create({{readDataUrl:async()=>'',upload:async()=>{{}}}});
|
|
||||||
try {{
|
|
||||||
controller.restore({{filename:'saved.png',contentType:'image/png',stored:true}});
|
|
||||||
process.stdout.write(JSON.stringify({{restored:true,state:controller.state()}}));
|
|
||||||
}} catch (error) {{
|
|
||||||
process.stdout.write(JSON.stringify({{restored:false,message:error.message,state:controller.state()}}));
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
output = json.loads(run_node(script))
|
|
||||||
|
|
||||||
assert output["restored"] is False
|
|
||||||
assert "saved screenshot" in output["message"].lower()
|
|
||||||
assert output["state"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_issue_composer_renders_thumb_reachable_screenshot_preview():
|
def test_issue_composer_renders_thumb_reachable_screenshot_preview():
|
||||||
html = INDEX.read_text()
|
html = INDEX.read_text()
|
||||||
css = CSS.read_text()
|
css = CSS.read_text()
|
||||||
|
|
@ -163,29 +145,12 @@ def test_new_issue_sheet_captures_screenshot_into_durable_outbox():
|
||||||
assert 'id="remove-create-issue-attachment"' in html
|
assert 'id="remove-create-issue-attachment"' in html
|
||||||
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
|
assert "const createIssueAttachmentController = issueAttachment.mount({" in source
|
||||||
assert "attachment: await createIssueAttachmentController.serialize()" in source
|
assert "attachment: await createIssueAttachmentController.serialize()" in source
|
||||||
assert "createIssueAttachmentController.restore(hydrated.attachment);" in source
|
assert "createIssueAttachmentController.restore(queued.attachment);" in source
|
||||||
assert "createIssueAttachmentController.clear();" in source
|
assert "createIssueAttachmentController.clear();" in source
|
||||||
assert ".create-issue-attachment" in css
|
assert ".create-issue-attachment" in css
|
||||||
assert "overflow-x:hidden" in css
|
assert "overflow-x:hidden" in css
|
||||||
|
|
||||||
|
|
||||||
def test_queued_screenshot_is_hydrated_before_the_issue_editor_opens_and_failure_is_retryable():
|
|
||||||
source = DASHBOARD.read_text()
|
|
||||||
handler = re.search(
|
|
||||||
r"list\.querySelectorAll\('\.draft-edit'\).*?addEventListener\('click', async \(\) => \{(?P<body>.*?)\n \}\);",
|
|
||||||
source,
|
|
||||||
re.DOTALL,
|
|
||||||
)
|
|
||||||
assert handler is not None
|
|
||||||
body = handler.group("body")
|
|
||||||
assert "await issueOutbox.hydrateForEdit(queued.id)" in body
|
|
||||||
assert body.index("await issueOutbox.hydrateForEdit(queued.id)") < body.index(
|
|
||||||
"openCreateIssueSheet()"
|
|
||||||
)
|
|
||||||
assert "catch (error)" in body
|
|
||||||
assert "saved screenshot" in body.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_attachment_view_keeps_invalid_draft_and_removes_preview():
|
def test_attachment_view_keeps_invalid_draft_and_removes_preview():
|
||||||
script = f"""
|
script = f"""
|
||||||
const attachment = require({json.dumps(str(ATTACHMENT))});
|
const attachment = require({json.dumps(str(ATTACHMENT))});
|
||||||
|
|
|
||||||
|
|
@ -94,31 +94,6 @@ process.stdout.write(JSON.stringify(createIssueOutbox({{storage}}).list()[0]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_issue_outbox_hydrates_a_metadata_only_screenshot_for_edit_without_copying_bytes_to_localstorage():
|
|
||||||
script = f"""
|
|
||||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
|
||||||
const values = new Map();
|
|
||||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
|
||||||
values.set('stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[{{
|
|
||||||
id:'capture-1',operationId:'capture-1',repository:'o/r',title:'Before',body:'',
|
|
||||||
ownerLogin:'timmy',status:'queued',attachment:{{filename:'phone.png',contentType:'image/png',stored:true}},
|
|
||||||
}}]}}));
|
|
||||||
const gets=[];
|
|
||||||
const outbox=createIssueOutbox({{storage,backgroundSync:{{
|
|
||||||
get:async id=>{{gets.push(id);return {{id,operationId:'capture-1',attachment:{{filename:'phone.png',contentType:'image/png',data:'durable-image-bytes'}}}};}},
|
|
||||||
}}}});
|
|
||||||
(async()=>{{
|
|
||||||
const hydrated=await outbox.hydrateForEdit('capture-1');
|
|
||||||
process.stdout.write(JSON.stringify({{hydrated,gets,stored:values.get('stackchain.issue-outbox.v1')}}));
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
output = run_node(script)
|
|
||||||
|
|
||||||
assert output["hydrated"]["attachment"]["data"] == "durable-image-bytes"
|
|
||||||
assert output["gets"] == ["capture-1"]
|
|
||||||
assert "durable-image-bytes" not in output["stored"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_issue_outbox_persists_create_and_start_intent_and_returns_it_with_confirmation():
|
def test_issue_outbox_persists_create_and_start_intent_and_returns_it_with_confirmation():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user