Comment and continue to the next Today item #430
|
|
@ -40,7 +40,10 @@ another worker replays a confirmed result instead of posting duplicate content.
|
||||||
five-item Today plan syncs across the operator's devices. Starting a Today work session also
|
five-item Today plan syncs across the operator's devices. Starting a Today work session also
|
||||||
stores an account-bound checkpoint on the current device. After a reload or installed-app
|
stores an account-bound checkpoint on the current device. After a reload or installed-app
|
||||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
|
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
|
||||||
finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
|
**Comment & next** on that current issue or pull request posts the handoff online or admits it
|
||||||
|
to durable account-bound delivery, then removes the item only from Today and opens the next
|
||||||
|
one without closing or merging it. Delivery or local-admission failure preserves both the draft
|
||||||
|
and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
|
||||||
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
||||||
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
||||||
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
||||||
|
|
|
||||||
48
frontend/comment-next.js
Normal file
48
frontend/comment-next.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
function createCommentNext({ post, queue, canQueue, accept = () => undefined, complete }) {
|
||||||
|
let inFlight = null;
|
||||||
|
|
||||||
|
function submit(item, body, operationId = '') {
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
inFlight = (async () => {
|
||||||
|
try {
|
||||||
|
let comment;
|
||||||
|
try {
|
||||||
|
comment = await post(item, body);
|
||||||
|
} catch (error) {
|
||||||
|
if (!canQueue(error)) throw error;
|
||||||
|
const admission = await queue({
|
||||||
|
kind: item.kind === 'pull' ? 'pull-comment' : 'issue-comment',
|
||||||
|
repository: item.repository,
|
||||||
|
number: item.number,
|
||||||
|
body,
|
||||||
|
operationId: typeof operationId === 'function' ? operationId() : operationId,
|
||||||
|
});
|
||||||
|
if (!admission || (!admission.item && admission.durable !== true)) {
|
||||||
|
throw new Error('Comment was not saved for delivery.');
|
||||||
|
}
|
||||||
|
accept(item, { delivery: admission.background ? 'queued' : 'saved' });
|
||||||
|
return {
|
||||||
|
accepted: true,
|
||||||
|
delivery: admission.background ? 'queued' : 'saved',
|
||||||
|
background: Boolean(admission.background),
|
||||||
|
completed: Boolean(complete(item)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
accept(item, { delivery: 'posted', comment });
|
||||||
|
return {
|
||||||
|
accepted: true,
|
||||||
|
delivery: 'posted',
|
||||||
|
comment,
|
||||||
|
completed: Boolean(complete(item)),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { submit, busy: () => Boolean(inFlight) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentNext;
|
||||||
|
|
@ -226,6 +226,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||||
.issue-comment-composer button { min-height:44px; width:100%; }
|
.issue-comment-composer button { min-height:44px; width:100%; }
|
||||||
|
.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||||
|
.comment-actions button { min-height:44px; width:100%; }
|
||||||
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||||
.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; }
|
.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; }
|
||||||
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
|
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
|
||||||
|
|
|
||||||
|
|
@ -782,6 +782,44 @@
|
||||||
warm: warmTodayOffline,
|
warm: warmTodayOffline,
|
||||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||||
});
|
});
|
||||||
|
function setCommentNextVisibility(kind) {
|
||||||
|
const item = kind === 'issue' ? selectedIssue : selectedPull;
|
||||||
|
qs('#send-' + kind + '-comment-next').hidden = !item || !workSession.checkpointed(item);
|
||||||
|
}
|
||||||
|
const issueCommentNext = createCommentNext({
|
||||||
|
post: async (item, body) => {
|
||||||
|
const comment = await issueController.comment(item, body);
|
||||||
|
if (selectedIssue === item && issueConversation) renderIssueConversation(issueConversation.append(comment));
|
||||||
|
return comment;
|
||||||
|
},
|
||||||
|
queue: message => authoredOutbox.enqueueDurably(message),
|
||||||
|
canQueue: canQueueMessage,
|
||||||
|
accept: item => {
|
||||||
|
issueController.saveDraft(item, '');
|
||||||
|
if (selectedIssue === item) qs('#issue-comment').value = '';
|
||||||
|
},
|
||||||
|
complete: item => completeTodayItem(item, {
|
||||||
|
successMessage: 'Comment saved. Next Today item opened.',
|
||||||
|
failureMessage: 'Comment saved, but Today still needs completion.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const pullCommentNext = createCommentNext({
|
||||||
|
post: async (item, body) => {
|
||||||
|
const comment = await pullController.comment(item, body);
|
||||||
|
if (selectedPull === item && pullConversation) renderPullConversation(pullConversation.append(comment));
|
||||||
|
return comment;
|
||||||
|
},
|
||||||
|
queue: message => authoredOutbox.enqueueDurably(message),
|
||||||
|
canQueue: canQueueMessage,
|
||||||
|
accept: item => {
|
||||||
|
pullController.saveDraft(item, '');
|
||||||
|
if (selectedPull === item) qs('#pull-comment').value = '';
|
||||||
|
},
|
||||||
|
complete: item => completeTodayItem(item, {
|
||||||
|
successMessage: 'Comment saved. Next Today item opened.',
|
||||||
|
failureMessage: 'Comment saved, but Today still needs completion.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
const closeOfflineIssue = createOfflineIssueClose({
|
const closeOfflineIssue = createOfflineIssueClose({
|
||||||
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
|
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
|
||||||
completeToday: (item, options) => completeTodayItem(item, options),
|
completeToday: (item, options) => completeTodayItem(item, options),
|
||||||
|
|
@ -1732,6 +1770,7 @@
|
||||||
qs('#retry-issue-load').hidden = true;
|
qs('#retry-issue-load').hidden = true;
|
||||||
qs('#open-issue-gitea').href = item.url || '#';
|
qs('#open-issue-gitea').href = item.url || '#';
|
||||||
qs('#send-issue-comment').disabled = false;
|
qs('#send-issue-comment').disabled = false;
|
||||||
|
setCommentNextVisibility('issue');
|
||||||
qs('#edit-issue-content').disabled = true;
|
qs('#edit-issue-content').disabled = true;
|
||||||
qs('#issue-edit-form').hidden = true;
|
qs('#issue-edit-form').hidden = true;
|
||||||
qs('#issue-edit-status').textContent = '';
|
qs('#issue-edit-status').textContent = '';
|
||||||
|
|
@ -1894,6 +1933,7 @@
|
||||||
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
|
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
|
||||||
qs('#retry-pull-load').hidden = true;
|
qs('#retry-pull-load').hidden = true;
|
||||||
qs('#open-pull-gitea').href = item.url || '#';
|
qs('#open-pull-gitea').href = item.url || '#';
|
||||||
|
setCommentNextVisibility('pull');
|
||||||
qs('#close-pull-sheet').focus();
|
qs('#close-pull-sheet').focus();
|
||||||
try {
|
try {
|
||||||
const detail = offlineDetail || await pullController.load(item);
|
const detail = offlineDetail || await pullController.load(item);
|
||||||
|
|
@ -3351,6 +3391,43 @@
|
||||||
qs('#issue-milestone').disabled = false;
|
qs('#issue-milestone').disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
async function submitCommentAndNext(kind) {
|
||||||
|
const item = kind === 'issue' ? selectedIssue : selectedPull;
|
||||||
|
if (!item || !workSession.checkpointed(item)) return;
|
||||||
|
const textarea = qs('#' + kind + '-comment');
|
||||||
|
const status = qs('#' + kind + '-comment-status');
|
||||||
|
const body = textarea.value.trim();
|
||||||
|
if (!body) {
|
||||||
|
status.textContent = 'Write a comment before posting.';
|
||||||
|
textarea.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const postButton = qs('#send-' + kind + '-comment');
|
||||||
|
const nextButton = qs('#send-' + kind + '-comment-next');
|
||||||
|
const controller = kind === 'issue' ? issueCommentNext : pullCommentNext;
|
||||||
|
const operationId = () => localStorage.getItem('stackchain.' + kind + '-comment.v1:' +
|
||||||
|
item.repository + '#' + item.number + ':operation');
|
||||||
|
postButton.disabled = true;
|
||||||
|
nextButton.disabled = true;
|
||||||
|
status.textContent = 'Posting comment and opening next…';
|
||||||
|
try {
|
||||||
|
const result = await controller.submit(item, body, operationId);
|
||||||
|
const stillOpen = kind === 'issue' ? selectedIssue === item : selectedPull === item;
|
||||||
|
if (!stillOpen) return;
|
||||||
|
if (!result.completed) status.textContent = 'Comment saved, but Today still needs completion.';
|
||||||
|
else if (result.delivery === 'posted') status.textContent = 'Comment posted.';
|
||||||
|
else if (result.background) status.textContent = 'Queued for sync when the connection returns.';
|
||||||
|
else status.textContent = 'Saved for next launch; background delivery unavailable.';
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = error.message + ' Your draft and Today position are safe; retry.';
|
||||||
|
textarea.focus();
|
||||||
|
} finally {
|
||||||
|
postButton.disabled = false;
|
||||||
|
nextButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qs('#send-issue-comment-next').addEventListener('click', () => submitCommentAndNext('issue'));
|
||||||
|
qs('#send-pull-comment-next').addEventListener('click', () => submitCommentAndNext('pull'));
|
||||||
qs('#send-issue-comment').addEventListener('click', async () => {
|
qs('#send-issue-comment').addEventListener('click', async () => {
|
||||||
if (!selectedIssue) return;
|
if (!selectedIssue) return;
|
||||||
const body = qs('#issue-comment').value.trim();
|
const body = qs('#issue-comment').value.trim();
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,10 @@
|
||||||
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
|
<section class="issue-comment-composer" aria-labelledby="issue-comment-title">
|
||||||
<h2 id="issue-comment-title">Add comment</h2>
|
<h2 id="issue-comment-title">Add comment</h2>
|
||||||
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
<textarea id="issue-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
||||||
<button id="send-issue-comment" type="button">Post comment</button>
|
<div class="comment-actions">
|
||||||
|
<button id="send-issue-comment" type="button">Post comment</button>
|
||||||
|
<button id="send-issue-comment-next" type="button" hidden>Comment & next</button>
|
||||||
|
</div>
|
||||||
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
|
<div id="issue-comment-status" class="small" aria-live="assertive"></div>
|
||||||
</section>
|
</section>
|
||||||
<details class="issue-planning" id="issue-planning">
|
<details class="issue-planning" id="issue-planning">
|
||||||
|
|
@ -470,7 +473,10 @@
|
||||||
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
|
<section class="pull-comment-composer" aria-labelledby="pull-comment-title">
|
||||||
<h2 id="pull-comment-title">Add comment</h2>
|
<h2 id="pull-comment-title">Add comment</h2>
|
||||||
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
<textarea id="pull-comment" maxlength="10000" placeholder="Write a comment"></textarea>
|
||||||
<button id="send-pull-comment" type="button">Post comment</button>
|
<div class="comment-actions">
|
||||||
|
<button id="send-pull-comment" type="button">Post comment</button>
|
||||||
|
<button id="send-pull-comment-next" type="button" hidden>Comment & next</button>
|
||||||
|
</div>
|
||||||
<div id="pull-comment-status" class="small" aria-live="assertive"></div>
|
<div id="pull-comment-status" class="small" aria-live="assertive"></div>
|
||||||
</section>
|
</section>
|
||||||
<details class="pull-review" id="pull-review">
|
<details class="pull-review" id="pull-review">
|
||||||
|
|
@ -602,6 +608,7 @@
|
||||||
<script src="static/card-planning.js"></script>
|
<script src="static/card-planning.js"></script>
|
||||||
<script src="static/today-work.js"></script>
|
<script src="static/today-work.js"></script>
|
||||||
<script src="static/today-completion.js"></script>
|
<script src="static/today-completion.js"></script>
|
||||||
|
<script src="static/comment-next.js"></script>
|
||||||
<script src="static/plan-today.js"></script>
|
<script src="static/plan-today.js"></script>
|
||||||
<script src="static/plan-today-preview.js"></script>
|
<script src="static/plan-today-preview.js"></script>
|
||||||
<script src="static/today-sync.js"></script>
|
<script src="static/today-sync.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -563,7 +563,7 @@ function createWorkSession({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
active: () => running,
|
active: () => running,
|
||||||
checkpointed: () => running && durable,
|
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
|
||||||
end: () => finish(),
|
end: () => finish(),
|
||||||
resumable: () => Boolean(checkpoint?.read()),
|
resumable: () => Boolean(checkpoint?.read()),
|
||||||
reopen() {
|
reopen() {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v71';
|
const CACHE = 'stackchain-dashboard-shell-v72';
|
||||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
|
|
@ -29,6 +29,7 @@ const SHELL = [
|
||||||
BASE + 'static/card-planning.js',
|
BASE + 'static/card-planning.js',
|
||||||
BASE + 'static/today-work.js',
|
BASE + 'static/today-work.js',
|
||||||
BASE + 'static/today-completion.js',
|
BASE + 'static/today-completion.js',
|
||||||
|
BASE + 'static/comment-next.js',
|
||||||
BASE + 'static/plan-today.js',
|
BASE + 'static/plan-today.js',
|
||||||
BASE + 'static/plan-today-preview.js',
|
BASE + 'static/plan-today-preview.js',
|
||||||
BASE + 'static/today-sync.js',
|
BASE + 'static/today-sync.js',
|
||||||
|
|
|
||||||
212
tests/test_comment_next.py
Normal file
212
tests/test_comment_next.py
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.dashboard_bundle import dashboard
|
||||||
|
|
||||||
|
|
||||||
|
COMMENT_NEXT = Path(__file__).parents[1] / "frontend" / "comment-next.js"
|
||||||
|
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_node(script):
|
||||||
|
return subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_and_next_posts_then_completes_current_today_item_once():
|
||||||
|
script = f"""
|
||||||
|
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||||
|
const calls = [];
|
||||||
|
let finishPost;
|
||||||
|
const controller = createCommentNext({{
|
||||||
|
post: (item, body) => {{ calls.push(`post:${{item.number}}:${{body}}`); return new Promise(resolve => {{ finishPost = resolve; }}); }},
|
||||||
|
queue: () => {{ throw new Error('must not queue'); }},
|
||||||
|
canQueue: () => false,
|
||||||
|
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
|
||||||
|
}});
|
||||||
|
const item = {{kind:'issue', repository:'stackchain/dashboard', number:429}};
|
||||||
|
const first = controller.submit(item, 'Handoff ready');
|
||||||
|
const second = controller.submit(item, 'Handoff ready');
|
||||||
|
if (first !== second) throw new Error('submission was not single-flight');
|
||||||
|
finishPost({{id:7, body:'Handoff ready'}});
|
||||||
|
(async () => {{
|
||||||
|
const result = await first;
|
||||||
|
process.stdout.write(JSON.stringify({{result, calls, busy:controller.busy()}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert json.loads(run_node(script)) == {
|
||||||
|
"result": {
|
||||||
|
"accepted": True,
|
||||||
|
"delivery": "posted",
|
||||||
|
"comment": {"id": 7, "body": "Handoff ready"},
|
||||||
|
"completed": True,
|
||||||
|
},
|
||||||
|
"calls": ["post:429:Handoff ready", "complete:429"],
|
||||||
|
"busy": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_and_next_advances_after_durable_offline_admission():
|
||||||
|
script = f"""
|
||||||
|
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||||
|
const calls = [];
|
||||||
|
const retryable = new Error('offline'); retryable.status = 503;
|
||||||
|
const controller = createCommentNext({{
|
||||||
|
post: () => Promise.reject(retryable),
|
||||||
|
canQueue: error => error.status === 503,
|
||||||
|
queue: message => {{ calls.push(message); return Promise.resolve({{durable:true, background:false}}); }},
|
||||||
|
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
const result = await controller.submit({{kind:'pull', repository:'stackchain/dashboard', number:12}}, 'Please review', 'op-12');
|
||||||
|
process.stdout.write(JSON.stringify({{result, calls}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert json.loads(run_node(script)) == {
|
||||||
|
"result": {
|
||||||
|
"accepted": True,
|
||||||
|
"delivery": "saved",
|
||||||
|
"background": False,
|
||||||
|
"completed": True,
|
||||||
|
},
|
||||||
|
"calls": [
|
||||||
|
{
|
||||||
|
"kind": "pull-comment",
|
||||||
|
"repository": "stackchain/dashboard",
|
||||||
|
"number": 12,
|
||||||
|
"body": "Please review",
|
||||||
|
"operationId": "op-12",
|
||||||
|
},
|
||||||
|
"complete:12",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_and_next_reads_retry_identity_after_the_failed_post():
|
||||||
|
script = f"""
|
||||||
|
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||||
|
let operationId = '';
|
||||||
|
let queued;
|
||||||
|
const controller = createCommentNext({{
|
||||||
|
post: () => {{ operationId = 'post-attempt-id'; return Promise.reject(Object.assign(new Error('timeout'), {{status:503}})); }},
|
||||||
|
canQueue: () => true,
|
||||||
|
queue: message => {{ queued = message; return Promise.resolve({{durable:true}}); }},
|
||||||
|
complete: () => true,
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
const result = await controller.submit(
|
||||||
|
{{kind:'issue', repository:'r', number:3}}, 'Status', () => operationId
|
||||||
|
);
|
||||||
|
process.stdout.write(JSON.stringify({{result, queued}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
|
||||||
|
output = json.loads(run_node(script))
|
||||||
|
assert output["result"]["accepted"] is True
|
||||||
|
assert output["result"]["delivery"] == "saved"
|
||||||
|
assert output["queued"]["operationId"] == "post-attempt-id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_and_next_clears_the_completed_draft_before_opening_next_item():
|
||||||
|
script = f"""
|
||||||
|
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||||
|
const calls = [];
|
||||||
|
const controller = createCommentNext({{
|
||||||
|
post: () => Promise.resolve({{id:1}}), queue: () => null, canQueue: () => false,
|
||||||
|
accept: item => calls.push(`clear:${{item.number}}`),
|
||||||
|
complete: item => {{ calls.push(`complete:${{item.number}}`); return true; }},
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
await controller.submit({{kind:'issue',repository:'r',number:8}}, 'Done');
|
||||||
|
process.stdout.write(JSON.stringify(calls));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert json.loads(run_node(script)) == ["clear:8", "complete:8"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_and_next_keeps_today_position_when_delivery_or_completion_fails():
|
||||||
|
script = f"""
|
||||||
|
const createCommentNext = require({json.dumps(str(COMMENT_NEXT))});
|
||||||
|
const failures = [];
|
||||||
|
const permanent = createCommentNext({{
|
||||||
|
post: () => Promise.reject(Object.assign(new Error('forbidden'), {{status:403}})),
|
||||||
|
canQueue: () => false,
|
||||||
|
queue: () => Promise.resolve({{durable:true}}),
|
||||||
|
complete: () => {{ failures.push('advanced'); return true; }},
|
||||||
|
}});
|
||||||
|
const notDurable = createCommentNext({{
|
||||||
|
post: () => Promise.reject(Object.assign(new Error('offline'), {{status:503}})),
|
||||||
|
canQueue: () => true,
|
||||||
|
queue: () => Promise.resolve({{durable:false, background:false}}),
|
||||||
|
complete: () => {{ failures.push('advanced'); return true; }},
|
||||||
|
}});
|
||||||
|
const cannotComplete = createCommentNext({{
|
||||||
|
post: () => Promise.resolve({{id:9}}), canQueue: () => false, queue: () => null,
|
||||||
|
complete: () => false,
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
for (const [name, controller] of [['permanent', permanent], ['notDurable', notDurable]]) {{
|
||||||
|
try {{ await controller.submit({{kind:'issue',repository:'r',number:1}}, 'draft'); }}
|
||||||
|
catch (error) {{ failures.push(`${{name}}:${{error.message}}`); }}
|
||||||
|
}}
|
||||||
|
const partial = await cannotComplete.submit({{kind:'issue',repository:'r',number:1}}, 'draft');
|
||||||
|
process.stdout.write(JSON.stringify({{failures, partial}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert json.loads(run_node(script)) == {
|
||||||
|
"failures": ["permanent:forbidden", "notDurable:Comment was not saved for delivery."],
|
||||||
|
"partial": {
|
||||||
|
"accepted": True,
|
||||||
|
"delivery": "posted",
|
||||||
|
"comment": {"id": 9},
|
||||||
|
"completed": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_checkpoint_only_matches_the_current_session_item():
|
||||||
|
script = f"""
|
||||||
|
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||||
|
const items = [
|
||||||
|
{{kind:'issue', repository:'r', number:1}},
|
||||||
|
{{kind:'pull', repository:'r', number:2}},
|
||||||
|
];
|
||||||
|
const session = buildMyWork.createWorkSession({{
|
||||||
|
getItems: () => items, getFilter: () => 'all', checkpointEnabled: () => true,
|
||||||
|
checkpoint: {{save:()=>undefined, clear:()=>undefined, read:()=>null}},
|
||||||
|
onOpen:()=>undefined, onProgress:()=>undefined, onFinish:()=>undefined,
|
||||||
|
}});
|
||||||
|
session.start();
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
current: session.checkpointed(items[0]),
|
||||||
|
other: session.checkpointed(items[1]),
|
||||||
|
generic: session.checkpointed(),
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert json.loads(run_node(script)) == {
|
||||||
|
"current": True,
|
||||||
|
"other": False,
|
||||||
|
"generic": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_composers_offer_comment_and_next_only_for_today_checkpoint():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert 'id="send-issue-comment-next"' in html
|
||||||
|
assert 'id="send-pull-comment-next"' in html
|
||||||
|
assert html.count('>Comment & next</button>') == 2
|
||||||
|
assert "setCommentNextVisibility('issue')" in html
|
||||||
|
assert "setCommentNextVisibility('pull')" in html
|
||||||
|
assert '.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||||
|
assert '.comment-actions button { min-height:44px;' in html
|
||||||
|
|
@ -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():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v71" in source
|
assert "stackchain-dashboard-shell-v72" in source
|
||||||
assert "BASE + 'static/later-sync.js'" in source
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -137,4 +137,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 { 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 pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" in css
|
assert ".markdown-content a { min-height:44px;" in css
|
||||||
assert "stackchain-dashboard-shell-v71" in worker
|
assert "stackchain-dashboard-shell-v72" in worker
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
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 local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||||
assert "stackchain-dashboard-shell-v71" in worker
|
assert "stackchain-dashboard-shell-v72" in worker
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
source = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v71" in source
|
assert "stackchain-dashboard-shell-v72" in source
|
||||||
assert "BASE + 'static/plan-today.js'" in source
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{
|
||||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v71" in source
|
assert "stackchain-dashboard-shell-v72" in source
|
||||||
assert "BASE + 'static/my-work.js'" in source
|
assert "BASE + 'static/my-work.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
|
|
@ -131,7 +131,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
def test_offline_review_next_ships_today_completion_atomically():
|
def test_offline_review_next_ships_today_completion_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v71" in source
|
assert "stackchain-dashboard-shell-v72" in source
|
||||||
assert "BASE + 'static/today-completion.js'" in source
|
assert "BASE + 'static/today-completion.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -139,7 +139,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v71" in source
|
assert "stackchain-dashboard-shell-v72" in source
|
||||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -147,14 +147,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v71" in source
|
assert "stackchain-dashboard-shell-v72" in source
|
||||||
assert "BASE + 'static/later-picker.js'" in source
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v71" in source
|
assert "stackchain-dashboard-shell-v72" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
@ -163,21 +163,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v71" in source
|
assert "stackchain-dashboard-shell-v72" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v71" in source
|
assert "stackchain-dashboard-shell-v72" in source
|
||||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v71" in source
|
assert "stackchain-dashboard-shell-v72" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -358,7 +358,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():
|
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v71" in source
|
assert "stackchain-dashboard-shell-v72" in source
|
||||||
assert "BASE + 'static/queue-today.js'" in source
|
assert "BASE + 'static/queue-today.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -397,6 +397,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/card-planning.js",
|
"/dashboard/static/card-planning.js",
|
||||||
"/dashboard/static/today-work.js",
|
"/dashboard/static/today-work.js",
|
||||||
"/dashboard/static/today-completion.js",
|
"/dashboard/static/today-completion.js",
|
||||||
|
"/dashboard/static/comment-next.js",
|
||||||
"/dashboard/static/plan-today.js",
|
"/dashboard/static/plan-today.js",
|
||||||
"/dashboard/static/plan-today-preview.js",
|
"/dashboard/static/plan-today-preview.js",
|
||||||
"/dashboard/static/today-sync.js",
|
"/dashboard/static/today-sync.js",
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
|
||||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v71" in source
|
assert "stackchain-dashboard-shell-v72" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user