Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5c5c8ec31 | ||
|
|
87e0d74414 | ||
|
|
f031a37e8d | ||
|
|
057bec7dc2 | ||
|
|
654ae8714e | ||
|
|
a0dd044995 | ||
|
|
c93a682dfb | ||
|
|
5675ca5d41 | ||
|
|
0767ccaa9d | ||
|
|
c6eabb5fc7 | ||
|
|
77fab85a66 | ||
|
|
dcf26c896c | ||
|
|
fba1f4238d | ||
|
|
9e301924b4 | ||
|
|
8edd542379 | ||
|
|
0fbe047e03 | ||
|
|
995d8f8527 | ||
|
|
fabd6123f8 | ||
|
|
2553e7121b | ||
|
|
681e10c7db | ||
|
|
d96a77c5d5 | ||
|
|
97eb928187 | ||
|
|
80a4096118 | ||
|
|
f04a547e19 | ||
|
|
ea3712ca3e | ||
|
|
7421a8f62d | ||
|
|
982c2a1fb7 | ||
|
|
3e7d6f7111 | ||
|
|
32394fcd8f | ||
|
|
ea3194cdca | ||
|
|
85043b4af6 | ||
|
|
dc202bde11 | ||
|
|
c4ba78c8cb | ||
|
|
e35d7dea35 | ||
|
|
b81d695585 | ||
|
|
75da76c6f5 | ||
|
|
f78d69fba5 | ||
|
|
8ce64d2aa7 | ||
|
|
48f75cff05 | ||
|
|
58260d8a26 | ||
|
|
54ff44462e | ||
|
|
a87a5a4a90 | ||
|
|
c91194c283 | ||
|
|
5806d47b61 | ||
|
|
95f24a4e90 | ||
|
|
c016261b1a | ||
|
|
bbc3e10525 | ||
|
|
9737300d55 | ||
|
|
a08a51af02 | ||
|
|
528d721cd3 | ||
|
|
a91788276c | ||
|
|
28a2c9faf1 | ||
|
|
b8befc2041 | ||
|
|
d23c0c894b | ||
|
|
7948d2e353 | ||
|
|
1079ec6561 | ||
|
|
8d142c808b | ||
|
|
776ae70019 | ||
|
|
285fdff869 | ||
|
|
89d153d6a5 | ||
|
|
fcb0575e38 | ||
|
|
14ba1bcf14 | ||
|
|
a68639668c | ||
|
|
1f0e9fb59d | ||
|
|
065275b4ef |
No files matched your search
@@ -199,6 +199,9 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||
# three individual alerts followed by one private digest that opens Updates. A later
|
||||
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
|
||||
# that thread's updated_at revision; unchanged and older snapshots remain silent.
|
||||
# Browser push services must resolve exclusively to public IP addresses. Stackchain
|
||||
# validates endpoints at enrollment and again before delivery, rejects redirects,
|
||||
# and removes legacy subscriptions that resolve to private or reserved networks.
|
||||
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
|
||||
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
|
||||
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
|
||||
@@ -271,7 +274,12 @@ session linked to the credential is revoked atomically; unrelated credentials an
|
||||
sessions remain valid. Removing the current device's passkey keeps its current
|
||||
session active, so the sheet warns that the recovery token will be required after
|
||||
sign-out. Remotely revoking an enrolled device also deletes its passkey; signing out
|
||||
normally keeps the passkey available for the next sign-in.
|
||||
normally keeps the passkey available for the next sign-in. Passkey assertion counters
|
||||
advance with an atomic compare-and-swap across workers: stale or equal nonzero counters
|
||||
fail before a session or authorization grant is issued, while counterless authenticators
|
||||
remain compatible. The bounded **Passkey counter anomaly** Security activity entry names
|
||||
only the device label and attempted action; repeated alerts should prompt removal and
|
||||
re-enrollment of that passkey.
|
||||
|
||||
High-impact actions—merging a pull request, closing an assigned issue, revoking a
|
||||
remote device, or signing out every device—require a passkey assertion or the
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
function createBatchFindWork({
|
||||
capacity,
|
||||
claim,
|
||||
queue,
|
||||
timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }),
|
||||
persistEstimate = () => {},
|
||||
onProgress = () => {},
|
||||
storage = typeof localStorage === 'undefined' ? null : localStorage,
|
||||
owner = () => '',
|
||||
}) {
|
||||
let request = null;
|
||||
const journalKey = () => 'stackchain.find-work-batch.v1.' + encodeURIComponent(String(owner() || ''));
|
||||
|
||||
function readJournal() {
|
||||
if (!storage || !String(owner() || '')) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(journalKey()) || 'null');
|
||||
return value?.owner === String(owner()) && Array.isArray(value.items) ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function writeJournal(value) {
|
||||
if (!storage || !String(owner() || '')) return;
|
||||
storage.setItem(journalKey(), JSON.stringify(value));
|
||||
}
|
||||
|
||||
function clearJournal() {
|
||||
if (storage && String(owner() || '')) storage.removeItem(journalKey());
|
||||
}
|
||||
|
||||
function result(status, selected, available, queued = [], failed = []) {
|
||||
return { status, selected, available, queued, failed };
|
||||
}
|
||||
|
||||
function key(item) {
|
||||
return String(item.repository || '') + '#' + String(item.number || '');
|
||||
}
|
||||
|
||||
async function processJournal(journal) {
|
||||
const selected = journal.items;
|
||||
const queued = [];
|
||||
const failed = [];
|
||||
for (let index = 0; index < selected.length; index += 1) {
|
||||
const entry = selected[index];
|
||||
const item = entry.item;
|
||||
if (entry.state === 'queued') {
|
||||
queued.push(key(item));
|
||||
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const confirmed = entry.confirmed || await claim(item);
|
||||
if (!entry.confirmed) {
|
||||
entry.confirmed = confirmed;
|
||||
entry.state = 'assigned';
|
||||
writeJournal(journal);
|
||||
}
|
||||
const queueResult = await queue(confirmed);
|
||||
if (queueResult === 'queued' || queueResult === 'exists') {
|
||||
queued.push(key(item));
|
||||
entry.state = 'queued';
|
||||
if (Number.isInteger(entry.estimate) && entry.estimate > 0) {
|
||||
persistEstimate(confirmed, entry.estimate);
|
||||
}
|
||||
writeJournal(journal);
|
||||
} else {
|
||||
failed.push({ key: key(item), reason: 'assigned but Today sync is unavailable', assigned: true });
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
|
||||
}
|
||||
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||
}
|
||||
const outcome = result('complete', selected.length, journal.available, queued, failed);
|
||||
if (!failed.length) clearJournal();
|
||||
else writeJournal(journal);
|
||||
onProgress({ ...outcome, processed: selected.length });
|
||||
return outcome;
|
||||
}
|
||||
|
||||
function run(items, estimates = {}) {
|
||||
if (request) return request;
|
||||
const selected = Array.isArray(items) ? items.slice() : [];
|
||||
const available = Math.max(0, Number(capacity()) || 0);
|
||||
if (selected.length > available) {
|
||||
const outcome = result('full', selected.length, available);
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
const budget = timeBudget() || {};
|
||||
if (Number.isInteger(budget.capacity_minutes) && budget.capacity_minutes > 0) {
|
||||
const remaining = Math.max(0, budget.capacity_minutes - (Number(budget.planned_minutes) || 0));
|
||||
const invalid = selected.map(key).filter(id =>
|
||||
!Number.isInteger(estimates[id]) || estimates[id] <= 0
|
||||
);
|
||||
const requested = selected.reduce((sum, item) => sum +
|
||||
(Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0 ? estimates[key(item)] : 0), 0);
|
||||
if (invalid.length) {
|
||||
const outcome = { ...result('estimates-required', selected.length, available),
|
||||
remaining_minutes: remaining, requested_minutes: requested, invalid };
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
if (requested > remaining) {
|
||||
const outcome = { ...result('over-budget', selected.length, available),
|
||||
remaining_minutes: remaining, requested_minutes: requested, over_minutes: requested - remaining };
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
}
|
||||
const journal = {
|
||||
owner: String(owner() || ''), available,
|
||||
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
|
||||
};
|
||||
writeJournal(journal);
|
||||
request = processJournal(journal).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (request) return request;
|
||||
const journal = readJournal();
|
||||
if (!journal) return Promise.resolve(null);
|
||||
request = processJournal(journal).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
function mountRecovery(button, opener) {
|
||||
const show = () => {
|
||||
const journal = readJournal();
|
||||
button.hidden = !journal;
|
||||
if (journal) button.textContent = 'Resume ' + journal.items.filter(item => item.state !== 'queued').length + ' interrupted';
|
||||
};
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
const outcome = await resume();
|
||||
button.disabled = false;
|
||||
show();
|
||||
if (outcome) {
|
||||
button.previousElementSibling.textContent = outcome.failed.length ?
|
||||
outcome.failed.length + ' still need recovery.' : outcome.queued.length + ' queued · batch recovered.';
|
||||
}
|
||||
});
|
||||
opener.addEventListener('click', show);
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') mountRecovery(
|
||||
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
|
||||
);
|
||||
return { run, resume, mountRecovery };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
|
||||
@@ -22,6 +22,7 @@ function normalizeSharedContent(value = {}) {
|
||||
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
|
||||
const storageKey = 'stackchain.issue-capture.v1';
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
const followUpStorageKey = 'stackchain.issue-follow-up.v1';
|
||||
let pending = null;
|
||||
let duplicateRequest = 0;
|
||||
let repositorySearchRequest = 0;
|
||||
@@ -126,6 +127,42 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
return {status: 'ready'};
|
||||
}
|
||||
|
||||
function pendingFollowUp() {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(followUpStorageKey) || 'null');
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(parsed.repository || '')) ? String(parsed.repository) : '';
|
||||
const title = String(parsed.title || '').trim().slice(0, 240);
|
||||
const body = String(parsed.body || '').trim().slice(0, 9500);
|
||||
return title || body ? {repository, title, body, labelIds: []} : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function acceptFollowUp() {
|
||||
const followUp = pendingFollowUp();
|
||||
if (!followUp) return loadDraft();
|
||||
const accepted = saveDraft(followUp);
|
||||
try { storage.removeItem(followUpStorageKey); }
|
||||
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function discardFollowUp() {
|
||||
try { storage.removeItem(followUpStorageKey); }
|
||||
catch (_error) { /* The existing capture remains authoritative. */ }
|
||||
}
|
||||
|
||||
function stageFollowUp(value) {
|
||||
const followUp = {repository: String(value?.repository || ''), title: String(value?.title || ''), body: String(value?.body || '')};
|
||||
if (!followUp.title && !followUp.body) return {status: 'empty'};
|
||||
try { storage.setItem(followUpStorageKey, JSON.stringify(followUp)); }
|
||||
catch (_error) { /* The in-page flow can still continue. */ }
|
||||
const existing = loadDraft();
|
||||
if (existing.title || existing.body) return {status: 'conflict'};
|
||||
acceptFollowUp();
|
||||
return {status: 'ready'};
|
||||
}
|
||||
|
||||
function loadLabels(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
||||
@@ -254,6 +291,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
searchRepositories, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+88
-6
@@ -10,6 +10,17 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
|
||||
.notification-undo[hidden] { display:none; }
|
||||
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
|
||||
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.draft-capacity-sheet[hidden] { display:none; }
|
||||
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.draft-capacity-panel h3 { margin:.25rem 0; overflow-wrap:anywhere; }
|
||||
.draft-capacity-oldest { margin:14px 0; padding:12px; border:1px solid #31577f; border-radius:12px; overflow-wrap:anywhere; }
|
||||
.draft-capacity-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.draft-capacity-actions button { min-height:44px; width:100%; }
|
||||
#replace-oldest-draft { grid-column:1 / -1; border-color:#b45309; }
|
||||
#sign-out-all { min-height:44px; }
|
||||
.active-devices-sheet { position:fixed; inset:0; z-index:80; display:flex; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.active-devices-sheet[hidden] { display:none; }
|
||||
@@ -126,6 +137,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
|
||||
.plan-today-available span { display:flex; align-items:center; gap:6px; }
|
||||
.plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; }
|
||||
.plan-today-build { min-height:44px; width:100%; font-weight:700; }
|
||||
.plan-today-build-status { min-height:1.4em; margin-top:6px; color:#bfdbfe; }
|
||||
.plan-today-estimates { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.plan-today-estimates h3, .plan-today-estimates p { margin-top:0; }
|
||||
.plan-today-estimates input { min-height:44px; width:108px; box-sizing:border-box; }
|
||||
.plan-today-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:8px 0; border-top:1px solid #29496d; }
|
||||
.plan-today-estimate-row label { display:flex; align-items:center; gap:6px; }
|
||||
.plan-today-skipped { display:grid; gap:6px; margin:8px 0; }
|
||||
.plan-today-skipped-item { padding:8px 10px; border-left:3px solid #b45309; background:#261a13; overflow-wrap:anywhere; }
|
||||
.plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; }
|
||||
.plan-today-error { min-height:1.4em; color:#fca5a5; }
|
||||
.discard-recap-replan { min-height:44px; margin-bottom:8px; }
|
||||
@@ -188,8 +208,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
||||
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
|
||||
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.queue-finder { display:grid; gap:6px; margin:10px 0; }
|
||||
.queue-finder > label { font-weight:700; }
|
||||
.queue-finder-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
.queue-finder input, .queue-finder button { min-height:44px; box-sizing:border-box; }
|
||||
.queue-finder input { min-width:0; width:100%; padding:8px 10px; border:1px solid #31577f; border-radius:10px; background:#08111f; color:var(--text); font:inherit; }
|
||||
.queue-finder button { padding-inline:14px; }
|
||||
#search-older-work { width:100%; }
|
||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
|
||||
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); }
|
||||
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
|
||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.draft-actions button { min-height:44px; width:100%; }
|
||||
@@ -202,6 +230,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
|
||||
.create-issue-actions #create-issue-status { grid-column:1/-1; }
|
||||
.draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); }
|
||||
.draft-filing-session[hidden] { display:none; }
|
||||
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.draft-filing-session-actions button { min-height:44px; width:100%; }
|
||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
@@ -209,8 +241,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.update-selection-controls button { min-height:44px; }
|
||||
.update-selector { min-height:44px; display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||
.update-selector input { width:22px; height:22px; flex:0 0 auto; }
|
||||
.selection-scope-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; max-width:100%; }
|
||||
.selection-scope-actions button { min-height:44px; max-width:100%; }
|
||||
.update-selector { min-height:44px; }
|
||||
.work-selector { min-height:44px; }
|
||||
.update-selector, .work-selector { display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||
.update-selector input, .work-selector input { width:22px; height:22px; flex:0 0 auto; }
|
||||
.my-work-card.selection-active { border-color:#31577f; }
|
||||
.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
|
||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
@@ -243,6 +279,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.retry-work-route[hidden] { display:none; }
|
||||
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.my-work-bulk button { min-height:44px; width:100%; }
|
||||
.my-work-bulk-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.my-work-bulk-actions button { min-height:44px; width:100%; }
|
||||
.my-work-bulk-actions #bulk-mark-read { grid-column:1 / -1; }
|
||||
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
||||
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.review-sheet.open { display:flex; }
|
||||
@@ -414,9 +453,38 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.find-work-sheet.open { display:flex; }
|
||||
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.find-work-header-actions { display:flex; gap:8px; }
|
||||
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
|
||||
.find-work-search { position:sticky; top:0; z-index:2; display:grid; gap:6px; padding:8px 0; background:#0b1526; }
|
||||
.find-work-search > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
.find-work-search input, .find-work-search button { min-height:44px; }
|
||||
.find-work-filters { border:1px solid #2a496e; border-radius:10px; padding:10px; }
|
||||
.find-work-filters summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
|
||||
.find-work-filter-panel, .find-work-filter-options { display:grid; gap:8px; }
|
||||
.find-work-filter-panel fieldset { min-width:0; border:0; padding:6px 0; margin:0; }
|
||||
.find-work-filter-options { grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); }
|
||||
.find-work-filter-option { min-height:44px; display:flex; align-items:center; gap:8px; padding:6px; overflow-wrap:anywhere; }
|
||||
.find-work-filter-option input { width:20px; height:20px; flex:0 0 auto; }
|
||||
#clear-find-work-filters { min-height:44px; width:100%; }
|
||||
.find-work-list { display:grid; gap:10px; }
|
||||
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.find-work-card.selected { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
|
||||
.find-work-select { display:flex; align-items:center; gap:10px; min-height:44px; }
|
||||
.find-work-select input { width:22px; height:22px; }
|
||||
.fill-find-work-today { min-height:44px; width:100%; font-weight:700; }
|
||||
.find-work-batch-actions { position:sticky; bottom:0; z-index:2; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:12px 0 calc(12px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
|
||||
.find-work-batch-actions[hidden] { display:none; }
|
||||
.find-work-batch-actions span { grid-column:1 / -1; }
|
||||
.find-work-batch-actions button { min-height:44px; }
|
||||
.find-work-batch-recovery { min-height:44px; width:100%; font-weight:700; }
|
||||
.find-work-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||
.find-work-estimate-review[hidden] { display:none; }
|
||||
.find-work-estimate-review > div:first-child, #find-work-estimate-list { display:grid; gap:8px; }
|
||||
.find-work-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
|
||||
.find-work-estimate-row label { display:flex; align-items:center; gap:6px; }
|
||||
.find-work-estimate-review input { min-height:44px; width:76px; }
|
||||
.find-work-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.find-work-estimate-actions button { min-height:44px; }
|
||||
.find-work-card button { width:100%; font-weight:700; }
|
||||
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
|
||||
@@ -508,6 +576,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.mobile-task-dock { display:none; }
|
||||
.mobile-task-dock[hidden] { display:none; }
|
||||
|
||||
.mobile-today-hud { display:none; }
|
||||
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
|
||||
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||
@@ -535,11 +604,11 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.my-work { margin:0; }
|
||||
.attention-interruption:not([hidden]) { position:sticky; top:64px; z-index:6; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); }
|
||||
.my-work-header { align-items:flex-start; }
|
||||
.my-work-actions { width:100%; flex-wrap:nowrap; }
|
||||
.my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; }
|
||||
.my-work-actions { display:none; }
|
||||
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
|
||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||
.queue-finder { position:sticky; top:64px; z-index:5; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); padding:8px; background:rgba(11,21,38,.98); border:1px solid #2a496e; border-radius:10px; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
|
||||
.my-work-card { min-width:0; overflow-x:hidden; }
|
||||
@@ -571,14 +640,27 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-panel.composer-keyboard-active,
|
||||
.issue-sheet-panel.composer-keyboard-active,
|
||||
.pull-sheet-panel.composer-keyboard-active,
|
||||
.review-sheet-panel.composer-keyboard-active,
|
||||
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px; }
|
||||
.composer-keyboard-active .create-issue-form,
|
||||
.composer-keyboard-active .issue-comment-composer,
|
||||
.composer-keyboard-active .pull-comment-composer,
|
||||
.composer-keyboard-active .review-inline-composer,
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-task-dock[data-attention="true"] { grid-template-columns:repeat(6,minmax(0,1fr)); }
|
||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
|
||||
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
|
||||
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.mobile-queue-panel h2 { margin:0; }
|
||||
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
|
||||
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
|
||||
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
|
||||
.mobile-queue-list small { color:var(--muted); }
|
||||
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
||||
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
|
||||
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
|
||||
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
|
||||
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
|
||||
+551
-78
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,65 @@
|
||||
function showDraftCapacityDialog(captures) {
|
||||
const state = captures.capacity();
|
||||
if (!state.full) return false;
|
||||
const oldest = state.oldest;
|
||||
qs('#draft-capacity-oldest').innerHTML = '<strong>' + escapeHtml(oldest.title) + '</strong><div class="small">' +
|
||||
escapeHtml(oldest.body || 'No note') + (oldest.hasAttachment ? ' · Screenshot attached' : '') + '</div>';
|
||||
qs('#draft-capacity-sheet').dataset.oldestId = oldest.id;
|
||||
qs('#draft-capacity-status').textContent = '';
|
||||
qs('#draft-capacity-sheet').hidden = false;
|
||||
qs('#keep-editing-draft').focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function bindDraftCapacityDialog() {
|
||||
qs('#keep-editing-draft').addEventListener('click', () => {
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
qs('#save-unfiled-issue').focus();
|
||||
});
|
||||
qs('#review-full-drafts').addEventListener('click', () => {
|
||||
const oldestId = qs('#draft-capacity-sheet').dataset.oldestId;
|
||||
saveIssueCaptureDraft();
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
closeCreateIssueSheet(true, false);
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('queues');
|
||||
refreshMyWorkView();
|
||||
requestAnimationFrame(() => {
|
||||
const card = qs('[data-capture-id="' + CSS.escape(oldestId) + '"]');
|
||||
card?.scrollIntoView({block:'nearest'});
|
||||
card?.focus({preventScroll:true});
|
||||
});
|
||||
});
|
||||
qs('#replace-oldest-draft').addEventListener('click', async () => {
|
||||
const oldest = unfiledCaptures.capacity().oldest;
|
||||
if (!oldest || oldest.id !== qs('#draft-capacity-sheet').dataset.oldestId) {
|
||||
qs('#draft-capacity-status').textContent = 'Drafts changed. Review them before replacing anything.';
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('Replace “' + oldest.title + '” and permanently delete its saved contents?')) return;
|
||||
const button = qs('#replace-oldest-draft');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const draft = {
|
||||
title:qs('#create-issue-title').value.trim(), body:qs('#create-issue-body').value.trim(),
|
||||
attachment:await createIssueAttachmentController.serialize(),
|
||||
};
|
||||
const saved = await unfiledCaptures.replaceOldest(draft, oldest.id);
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
issueCapture.clearDraft();
|
||||
qs('#create-issue-title').value = '';
|
||||
qs('#create-issue-body').value = '';
|
||||
createIssueAttachmentController.clear();
|
||||
closeCreateIssueSheet(true, false);
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('queues');
|
||||
refreshMyWorkView();
|
||||
requestAnimationFrame(() => qs('[data-capture-id="' + CSS.escape(saved.id) + '"]')?.focus());
|
||||
qs('#my-work-action-status').textContent = 'Oldest Draft replaced. New work saved to Drafts.';
|
||||
} catch (error) {
|
||||
qs('#draft-capacity-status').textContent = error.message + ' Nothing was replaced.';
|
||||
} finally { button.disabled = false; }
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = {showDraftCapacityDialog, bindDraftCapacityDialog};
|
||||
@@ -0,0 +1,92 @@
|
||||
function createDraftFilingSession({list}) {
|
||||
let orderedIds = [];
|
||||
let currentId = '';
|
||||
|
||||
function visibleIds() {
|
||||
const available = new Set(list().map(item => item.id));
|
||||
orderedIds = orderedIds.filter(id => available.has(id));
|
||||
for (const item of list()) {
|
||||
if (!orderedIds.includes(item.id)) orderedIds.push(item.id);
|
||||
}
|
||||
return orderedIds;
|
||||
}
|
||||
|
||||
function state() {
|
||||
const ids = visibleIds();
|
||||
if (!ids.length) {
|
||||
currentId = '';
|
||||
return null;
|
||||
}
|
||||
if (!ids.includes(currentId)) currentId = ids[0];
|
||||
const index = ids.indexOf(currentId);
|
||||
return {id:currentId, position:index + 1, total:ids.length, remaining:ids.length - index - 1};
|
||||
}
|
||||
|
||||
function start(id) {
|
||||
orderedIds = list().map(item => item.id);
|
||||
if (!orderedIds.includes(id)) throw new Error('This capture is no longer available.');
|
||||
currentId = id;
|
||||
return state();
|
||||
}
|
||||
|
||||
function move(offset) {
|
||||
const ids = visibleIds();
|
||||
if (!ids.length) return state();
|
||||
const index = Math.max(0, ids.indexOf(currentId));
|
||||
currentId = ids[(index + offset + ids.length) % ids.length];
|
||||
return state();
|
||||
}
|
||||
|
||||
function removeCurrentAndNext(removedId) {
|
||||
const previousIds = orderedIds.slice();
|
||||
const removedIndex = Math.max(0, previousIds.indexOf(removedId));
|
||||
orderedIds = previousIds.filter(id => id !== removedId);
|
||||
const ids = visibleIds();
|
||||
if (!ids.length) return state();
|
||||
currentId = ids[Math.min(removedIndex, ids.length - 1)];
|
||||
return state();
|
||||
}
|
||||
|
||||
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
|
||||
session.attach = (qs, dependencies) => {
|
||||
const openCapture = async captureId => {
|
||||
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
|
||||
dependencies.issueCapture.saveDraft(resumed);
|
||||
dependencies.setResumedId(captureId);
|
||||
await dependencies.openSheet();
|
||||
dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment);
|
||||
dependencies.setFilingMode(true);
|
||||
session.render();
|
||||
};
|
||||
return Object.assign(session, {
|
||||
nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; },
|
||||
render() {
|
||||
const state = session.current();
|
||||
qs('#draft-filing-session').hidden = !state;
|
||||
if (!state) return;
|
||||
qs('#draft-filing-progress').textContent = `Draft ${state.position} of ${state.total}`;
|
||||
qs('#draft-filing-remaining').textContent = state.remaining ? `${state.remaining} remaining` : 'Last Draft';
|
||||
qs('#previous-draft').disabled = qs('#skip-draft').disabled = state.total < 2;
|
||||
qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft';
|
||||
},
|
||||
bind() {
|
||||
[['#previous-draft','previous'],['#skip-draft','next']].forEach(([selector, method]) =>
|
||||
qs(selector).addEventListener('click', async () => {
|
||||
const state = session[method]();
|
||||
if (state) await openCapture(state.id);
|
||||
})
|
||||
);
|
||||
},
|
||||
async advance(completedId) {
|
||||
const state = session.removeCurrentAndNext(completedId);
|
||||
if (state) { await openCapture(state.id); return true; }
|
||||
qs('#draft-filing-session').hidden = true;
|
||||
qs('#submit-new-issue').textContent = 'Create & assign to me';
|
||||
return false;
|
||||
},
|
||||
});
|
||||
};
|
||||
return session;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftFilingSession;
|
||||
+118
-4
@@ -163,10 +163,25 @@
|
||||
</div>
|
||||
</section>
|
||||
<div class="update-selection-controls" id="update-selection-controls" hidden>
|
||||
<button id="select-work" type="button">Select work</button>
|
||||
<button id="cancel-work-selection" type="button" hidden>Cancel selection</button>
|
||||
<button id="select-updates" type="button">Select updates</button>
|
||||
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
|
||||
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="selection-scope-actions" id="selection-scope-actions" hidden>
|
||||
<button id="select-matching-work" type="button">Select all matches</button>
|
||||
<button id="clear-work-selection" type="button">Clear selection</button>
|
||||
</div>
|
||||
<form class="queue-finder" id="queue-finder" role="search">
|
||||
<label for="queue-find-input">Find in <span id="queue-find-label">All</span></label>
|
||||
<div class="queue-finder-row">
|
||||
<input id="queue-find-input" type="search" autocomplete="off" aria-label="Find in active work queue" placeholder="Repository, #number, or title" />
|
||||
<button id="clear-queue-find" type="button" hidden>Clear</button>
|
||||
</div>
|
||||
<div class="small" id="queue-find-status" role="status" aria-live="polite"></div>
|
||||
<button id="search-older-work" type="button" hidden>Search older work</button>
|
||||
</form>
|
||||
<div class="my-work-list" id="my-work-list"></div>
|
||||
<div class="small" id="work-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
|
||||
@@ -178,7 +193,14 @@
|
||||
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
|
||||
<div class="small" id="work-route-share-status" aria-live="polite"></div>
|
||||
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
||||
<button id="bulk-mark-read" type="button"></button>
|
||||
<div class="my-work-bulk-actions">
|
||||
<button id="batch-add-today" type="button" hidden>Add to Today</button>
|
||||
<button data-batch-defer="today" type="button" hidden>Later today</button>
|
||||
<button data-batch-defer="tomorrow" type="button" hidden>Tomorrow</button>
|
||||
<button id="defer-selected-today" data-defer-selected="today" type="button">Later today</button>
|
||||
<button id="defer-selected-tomorrow" data-defer-selected="tomorrow" type="button">Tomorrow</button>
|
||||
<button id="bulk-mark-read" type="button"></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="sidebar">
|
||||
@@ -254,6 +276,14 @@
|
||||
<label class="plan-today-available" for="plan-today-available">Available today
|
||||
<span><input id="plan-today-available" type="number" inputmode="numeric" min="15" max="1440" step="15" placeholder="Minutes" /> min</span>
|
||||
</label>
|
||||
<button class="plan-today-build" id="build-today-plan" type="button">Build my Today</button>
|
||||
<div class="small plan-today-build-status" id="plan-today-build-status" role="status" aria-live="polite"></div>
|
||||
<section class="plan-today-estimates" id="plan-today-estimates" aria-labelledby="plan-today-estimates-heading" hidden>
|
||||
<h3 id="plan-today-estimates-heading">Estimate to finish your plan</h3>
|
||||
<p class="small muted">Add minutes for ready work and your plan will re-fit immediately.</p>
|
||||
<div id="plan-today-estimate-list"></div>
|
||||
</section>
|
||||
<div class="plan-today-skipped" id="plan-today-skipped"></div>
|
||||
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
|
||||
<button class="discard-recap-replan" id="discard-recap-replan" type="button" hidden>Discard recap feedback</button>
|
||||
<section aria-labelledby="today-plan-heading">
|
||||
@@ -502,12 +532,44 @@
|
||||
<section class="find-work-panel">
|
||||
<div class="find-work-header">
|
||||
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
|
||||
<button id="close-find-work" type="button">Close</button>
|
||||
<div class="find-work-header-actions"><button id="select-find-work" type="button">Select work</button><button id="close-find-work" type="button">Close</button></div>
|
||||
</div>
|
||||
<p class="small">Claim an available issue and continue it in My Work.</p>
|
||||
<form class="find-work-search" id="find-work-search-form" role="search">
|
||||
<label for="find-work-search">Search repository, #number, or title</label>
|
||||
<div><input id="find-work-search" type="search" maxlength="100" autocomplete="off" />
|
||||
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
|
||||
<span id="find-work-match-status" class="small" aria-live="polite"></span>
|
||||
</form>
|
||||
<details class="find-work-filters" id="find-work-filters">
|
||||
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
|
||||
<div class="find-work-filter-panel" aria-label="Filter available work">
|
||||
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
|
||||
<button id="clear-find-work-filters" type="button">Clear filters</button>
|
||||
</div>
|
||||
</details>
|
||||
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
|
||||
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
|
||||
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
|
||||
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
|
||||
aria-describedby="find-work-status" hidden>Resume assigning & queueing</button>
|
||||
<div class="find-work-list" id="find-work-list"></div>
|
||||
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
|
||||
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden>
|
||||
<div><strong id="find-work-estimate-heading">Fit this work into Today</strong>
|
||||
<span class="small" id="find-work-estimate-summary" aria-live="polite"></span></div>
|
||||
<div id="find-work-estimate-list"></div>
|
||||
<div class="find-work-estimate-actions">
|
||||
<button id="cancel-find-work-estimates" type="button">Back</button>
|
||||
<button id="confirm-find-work-estimates" type="button">Confirm & assign</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="find-work-batch-actions" id="batch-find-work-actions" hidden>
|
||||
<span id="find-work-selection-status" class="small" aria-live="polite">No work selected.</span>
|
||||
<button id="cancel-find-work-selection" type="button">Cancel</button>
|
||||
<button id="claim-selected-work" type="button" disabled>Assign & queue Today</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -550,6 +612,14 @@
|
||||
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
|
||||
</section>
|
||||
<section class="create-issue-filing" id="create-issue-filing" hidden>
|
||||
<aside class="draft-filing-session" id="draft-filing-session" aria-live="polite" hidden>
|
||||
<strong id="draft-filing-progress"></strong>
|
||||
<span class="small" id="draft-filing-remaining"></span>
|
||||
<div class="draft-filing-session-actions">
|
||||
<button id="previous-draft" type="button">Previous</button>
|
||||
<button id="skip-draft" type="button">Skip</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="create-issue-repository-picker">
|
||||
<label for="create-issue-repository-search">Repository search
|
||||
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
|
||||
@@ -584,6 +654,7 @@
|
||||
<div class="small">The issue will be assigned to you.</div>
|
||||
<div class="create-issue-actions">
|
||||
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
||||
<button id="create-follow-up-next" type="submit" hidden>Create follow-up & next</button>
|
||||
<button id="create-and-start-issue" type="submit">Create & start</button>
|
||||
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
||||
</div>
|
||||
@@ -593,6 +664,20 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="draft-capacity-sheet" id="draft-capacity-sheet" role="dialog" aria-modal="true" aria-labelledby="draft-capacity-heading" hidden>
|
||||
<section class="draft-capacity-panel">
|
||||
<h3 id="draft-capacity-heading">Drafts full — nothing was deleted.</h3>
|
||||
<p class="small">Review saved work, keep editing, or explicitly replace the oldest Draft.</p>
|
||||
<div class="draft-capacity-oldest" id="draft-capacity-oldest"></div>
|
||||
<div class="draft-capacity-actions">
|
||||
<button id="review-full-drafts" type="button">Review Drafts</button>
|
||||
<button id="keep-editing-draft" type="button">Keep editing</button>
|
||||
<button id="replace-oldest-draft" type="button">Replace oldest & save</button>
|
||||
</div>
|
||||
<div id="draft-capacity-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
|
||||
<section class="update-sheet-panel">
|
||||
<div class="update-sheet-header">
|
||||
@@ -644,6 +729,7 @@
|
||||
</div>
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>
|
||||
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
|
||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
@@ -658,6 +744,11 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="notification-undo" id="notification-undo" role="status" aria-live="assertive" hidden>
|
||||
<span id="notification-undo-status"></span>
|
||||
<button id="undo-notification" type="button" aria-label="Undo marking update read">Undo</button>
|
||||
</div>
|
||||
|
||||
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
|
||||
<section class="pull-sheet-panel">
|
||||
<div class="pull-sheet-header">
|
||||
@@ -824,13 +915,28 @@
|
||||
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
|
||||
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
|
||||
</section>
|
||||
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
|
||||
<section class="mobile-queue-panel">
|
||||
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
|
||||
<p class="small muted">Choose what to work through next.</p>
|
||||
<div class="mobile-queue-list">
|
||||
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
|
||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||
<button data-mobile-queue="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></span></button>
|
||||
<button data-mobile-queue="recaps" type="button"><span><strong>Recaps</strong><small>History</small></span></button>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="attention" type="button" aria-label="Attention, 0 items" hidden>Attention <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
||||
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
|
||||
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
|
||||
<button class="mobile-task-action" data-mobile-task="drafts" type="button">Drafts <span class="mobile-task-count" id="mobile-draft-count" aria-label="0 drafts">0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues, 0 items">Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
@@ -844,6 +950,8 @@
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
<script src="static/unfiled-captures.js"></script>
|
||||
<script src="static/draft-filing-session.js"></script>
|
||||
<script src="static/draft-capacity-dialog.js"></script>
|
||||
<script src="static/outbox-coordinator.js"></script>
|
||||
<script src="static/background-issue-sync.js"></script>
|
||||
<script src="static/issue-outbox.js"></script>
|
||||
@@ -854,7 +962,9 @@
|
||||
<script src="static/offline-work.js"></script>
|
||||
<script src="static/offline-today.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/notification-undo.js"></script>
|
||||
<script src="static/card-planning.js"></script>
|
||||
<script src="static/work-selection.js"></script>
|
||||
<script src="static/today-work.js"></script>
|
||||
<script src="static/today-timer.js"></script>
|
||||
<script src="static/today-recap.js"></script>
|
||||
@@ -863,16 +973,19 @@
|
||||
<script src="static/comment-next.js"></script>
|
||||
<script src="static/update-reply-read-next.js"></script>
|
||||
<script src="static/plan-today.js"></script>
|
||||
<script src="static/plan-today-readiness.js"></script>
|
||||
<script src="static/plan-today-preview.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
<script src="static/today-rollover.js"></script>
|
||||
<script src="static/update-ownership.js"></script>
|
||||
<script src="static/update-follow-up.js"></script>
|
||||
<script src="static/later-work.js"></script>
|
||||
<script src="static/later-sync.js"></script>
|
||||
<script src="static/later-and-start.js"></script>
|
||||
<script src="static/detail-defer.js"></script>
|
||||
<script src="static/later-picker.js"></script>
|
||||
<script src="static/pick-work.js"></script>
|
||||
<script src="static/batch-find-work.js"></script>
|
||||
<script src="static/conversation.js"></script>
|
||||
<script src="static/comment-actions.js"></script>
|
||||
<script src="static/issue-attachment.js"></script>
|
||||
@@ -888,6 +1001,7 @@
|
||||
<script src="static/context-poller.js"></script>
|
||||
<script src="static/mobile-task-dock.js"></script>
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-queue-launcher.js"></script>
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
|
||||
@@ -51,6 +51,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
||||
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId;
|
||||
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
||||
const attachment = captureAttachment(draft?.attachment);
|
||||
if (attachment) item.attachment = attachment;
|
||||
@@ -76,6 +78,9 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
|
||||
function enqueue(draft, mirror = true) {
|
||||
const items = read();
|
||||
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
||||
const existing = sourceCaptureId && items.find(item => item.sourceCaptureId === sourceCaptureId);
|
||||
if (existing) return { ...existing };
|
||||
const item = prepareItem(draft);
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
@@ -83,6 +88,16 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|
||||
}
|
||||
|
||||
async function enqueueDurably(draft) {
|
||||
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
||||
const existing = sourceCaptureId && read().find(item => item.sourceCaptureId === sourceCaptureId);
|
||||
if (existing) {
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item: { ...existing }, background: false, durability: 'foreground-only', reused: true };
|
||||
}
|
||||
await backgroundSync.reconcile(read());
|
||||
await backgroundSync.requestSync();
|
||||
return { item: { ...existing }, background: true, durability: 'background', reused: true };
|
||||
}
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
const item = enqueue(draft, false);
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
|
||||
+17
-1
@@ -52,6 +52,22 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
function deferMany(items, until) {
|
||||
const key = storageKey();
|
||||
const wake = new Date(until);
|
||||
const entries = (items || []).map(item => [identity(item), item]);
|
||||
if (!key) return 'unavailable';
|
||||
if (!entries.length || entries.some(([id]) => !id) || Number.isNaN(wake.getTime()) || wake <= now()) {
|
||||
return 'invalid';
|
||||
}
|
||||
const records = read();
|
||||
const wakeAt = wake.toISOString();
|
||||
entries.forEach(([id]) => { records[id] = wakeAt; });
|
||||
if (!write(records)) return 'unavailable';
|
||||
entries.forEach(([id]) => onChange('defer', id, wakeAt));
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
function presetUntil(preset) {
|
||||
const current = new Date(now());
|
||||
if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
|
||||
@@ -127,7 +143,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
return { active, later };
|
||||
}
|
||||
|
||||
return { identity, read, adopt, defer, restore, presetUntil, partition };
|
||||
return { identity, read, adopt, defer, deferMany, restore, presetUntil, partition };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
|
||||
@@ -0,0 +1,49 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileQueueLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileQueueLauncher(options) {
|
||||
const emptyMessages = {
|
||||
attention: 'No items need attention.',
|
||||
update: 'No unread updates are ready to open.',
|
||||
later: 'No deferred work is ready to open.',
|
||||
draft: 'No drafts are ready to open.',
|
||||
};
|
||||
const continuation = [
|
||||
['attention', 'Start Attention'],
|
||||
['today', 'Continue Today'],
|
||||
['later', 'Start Later'],
|
||||
['draft', 'Open Drafts'],
|
||||
];
|
||||
|
||||
function recommend() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const match = continuation.find(([name]) => Number(counts[name]) > 0);
|
||||
if (!match) return {name: 'find', count: 0, label: 'Find Work'};
|
||||
const [name, label] = match;
|
||||
const count = Math.max(0, Number(counts[name]) || 0);
|
||||
return {name, count, label: label + ' (' + count + ')'};
|
||||
}
|
||||
|
||||
function open(name) {
|
||||
if (name === 'today') return options.openToday();
|
||||
options.selectFilter(name);
|
||||
const action = options.firstAction(name);
|
||||
if (!action) {
|
||||
options.announce(emptyMessages[name] || 'No work is ready to open.');
|
||||
return 'empty';
|
||||
}
|
||||
action.click();
|
||||
return 'opened';
|
||||
}
|
||||
|
||||
function continueWork() {
|
||||
const next = recommend();
|
||||
if (next.name === 'find') {
|
||||
options.openFindWork();
|
||||
return 'find';
|
||||
}
|
||||
return open(next.name);
|
||||
}
|
||||
|
||||
return { open, recommend, continueWork };
|
||||
});
|
||||
@@ -9,14 +9,16 @@
|
||||
let wasHidden = false;
|
||||
|
||||
function select(name) {
|
||||
if (!buttons[name]) return false;
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (key === name) button.setAttribute('aria-current', 'page');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function refreshVisibility() {
|
||||
const hidden = overlays.some(overlay => overlay.classList.contains('open'));
|
||||
const hidden = overlays.some(overlay => overlay.open || overlay.classList.contains('open'));
|
||||
nav.hidden = hidden;
|
||||
if (options.sessionHud) {
|
||||
if (hidden) options.sessionHud.setAttribute('data-overlay-hidden', 'true');
|
||||
@@ -26,14 +28,37 @@
|
||||
wasHidden = hidden;
|
||||
}
|
||||
|
||||
function openQueues() {
|
||||
if (!options.queueSheet || options.queueSheet.open) return;
|
||||
options.queueSheet.showModal();
|
||||
}
|
||||
|
||||
function closeQueues() {
|
||||
options.queueSheet?.open && options.queueSheet.close();
|
||||
}
|
||||
|
||||
function start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
button.addEventListener('click', event => {
|
||||
launcher = event.currentTarget;
|
||||
select(name);
|
||||
options.actions[name]();
|
||||
name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
|
||||
});
|
||||
});
|
||||
if (options.queueSheet) {
|
||||
options.queueClose?.addEventListener('click', closeQueues);
|
||||
options.queueSheet.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
closeQueues();
|
||||
});
|
||||
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
|
||||
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
|
||||
row.addEventListener('click', () => {
|
||||
closeQueues();
|
||||
options.onSelectQueue?.(name, row);
|
||||
});
|
||||
});
|
||||
}
|
||||
options.observe(refreshVisibility, overlays);
|
||||
refreshVisibility();
|
||||
}
|
||||
@@ -68,5 +93,22 @@
|
||||
else nav.removeAttribute('data-attention');
|
||||
}
|
||||
|
||||
return {start, refreshVisibility, updateAttention, updateWork};
|
||||
function updateQueues(counts) {
|
||||
const names = 'today attention update later draft'.split(' ');
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const total = ['today', 'attention', 'later', 'draft'].reduce((sum, name) => sum + normalized[name], 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
});
|
||||
options.queueRows?.update?.setAttribute(
|
||||
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
|
||||
);
|
||||
if (options.queueBadge) {
|
||||
options.queueBadge.textContent = String(total);
|
||||
options.queueBadge.hidden = total === 0;
|
||||
}
|
||||
buttons.queues?.setAttribute('aria-label', 'Queues, ' + total + ' items');
|
||||
}
|
||||
|
||||
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
|
||||
});
|
||||
+14
-2
@@ -362,7 +362,7 @@ function createNotificationReader({
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, next: next || null };
|
||||
return { items: updated, item: current, next: next || null };
|
||||
}
|
||||
|
||||
async function open(item, savedDetail = null) {
|
||||
@@ -404,7 +404,8 @@ function createNotificationReader({
|
||||
return true;
|
||||
},
|
||||
acceptReadAndNext(items, item = selected) {
|
||||
if (!item || selected !== item) return false;
|
||||
if (!item || (selected && selected !== item)) return false;
|
||||
selected = item;
|
||||
return advanceAfterRead(items, item);
|
||||
},
|
||||
async loadOlder() {
|
||||
@@ -813,6 +814,16 @@ function summarizeMyWork(items) {
|
||||
return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
|
||||
}
|
||||
|
||||
function findQueueItems(items, query) {
|
||||
const needle = String(query || '').trim().toLowerCase();
|
||||
if (!needle) return (items || []).slice();
|
||||
return (items || []).filter(item => {
|
||||
const number = Number.isInteger(item?.number) ? '#' + item.number : '';
|
||||
return [item?.repository, item?.key, number, item?.title]
|
||||
.some(value => String(value || '').toLowerCase().includes(needle));
|
||||
});
|
||||
}
|
||||
|
||||
function countMyWork(items) {
|
||||
return {
|
||||
all: items.length,
|
||||
@@ -836,6 +847,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.replaceIssueMilestone = replaceIssueMilestone;
|
||||
buildMyWork.removeIssue = removeIssue;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.findQueueItems = findQueueItems;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
buildMyWork.acknowledgeNotification = acknowledgeNotification;
|
||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
function createNotificationUndo({ restore, onItems, onStatus, onOffer = () => {}, onClear = () => {} }) {
|
||||
let offered = null;
|
||||
let pending = false;
|
||||
|
||||
return {
|
||||
offer(item, items) {
|
||||
if (!item || !Number.isInteger(item.notification_id)) return false;
|
||||
offered = { item, items: Array.isArray(items) ? [...items] : [] };
|
||||
const label = item.key || 'Update';
|
||||
onStatus(label + ' marked read. Undo?');
|
||||
onOffer(item);
|
||||
return true;
|
||||
},
|
||||
async run() {
|
||||
if (!offered || pending) return false;
|
||||
const current = offered;
|
||||
const label = current.item.key || 'Update';
|
||||
pending = true;
|
||||
onStatus('Restoring ' + label + '…');
|
||||
try {
|
||||
await restore(current.item.notification_id);
|
||||
if (offered !== current) return false;
|
||||
const restored = [current.item].concat(current.items.filter(
|
||||
item => item?.notification_id !== current.item.notification_id
|
||||
));
|
||||
offered = null;
|
||||
onItems(restored, current.item);
|
||||
onStatus(label + ' is unread again.');
|
||||
onClear();
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus('Could not restore ' + label + '. Retry Undo.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
offered = null;
|
||||
onClear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function requestNotificationUnread(notificationId) {
|
||||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/unread', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Restore unread failed.');
|
||||
return payload;
|
||||
}
|
||||
|
||||
function createDashboardNotificationUndo({ restore, getItems, setItems, getNotifications, setNotifications, refresh, select }) {
|
||||
let timer = null;
|
||||
const controller = createNotificationUndo({
|
||||
restore,
|
||||
onItems: (items, restored) => {
|
||||
setItems(items);
|
||||
const notifications = getNotifications();
|
||||
if (!notifications.some(item => item.id === restored.notification_id)) {
|
||||
setNotifications([{ id:restored.notification_id }].concat(notifications));
|
||||
}
|
||||
refresh();
|
||||
},
|
||||
onStatus: message => {
|
||||
select('#notification-undo-status').textContent = message;
|
||||
select('#my-work-action-status').textContent = message;
|
||||
},
|
||||
onOffer: item => {
|
||||
clearTimeout(timer);
|
||||
select('#notification-undo').hidden = false;
|
||||
select('#undo-notification').disabled = false;
|
||||
select('#undo-notification').setAttribute('aria-label', 'Undo marking ' + (item.key || 'update') + ' read');
|
||||
timer = setTimeout(() => controller.clear(), 10000);
|
||||
},
|
||||
onClear: () => { select('#notification-undo').hidden = true; },
|
||||
});
|
||||
return controller;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createNotificationUndo;
|
||||
module.exports.createDashboardNotificationUndo = createDashboardNotificationUndo;
|
||||
}
|
||||
+115
-6
@@ -1,14 +1,26 @@
|
||||
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {}, onFacets = () => {} }) {
|
||||
let available = [];
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let loadRequest = null;
|
||||
let loadGeneration = 0;
|
||||
let query = '';
|
||||
let filters = { repositories: [], labels: [] };
|
||||
let facetView;
|
||||
let claimRequest = null;
|
||||
const previewed = new Set();
|
||||
let selecting = false;
|
||||
const selected = new Map();
|
||||
|
||||
function itemKey(item) {
|
||||
return String(item?.repository || '') + '#' + String(item?.number || '');
|
||||
}
|
||||
|
||||
function emitSelection() {
|
||||
const state = { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
|
||||
onSelection(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function apply(result, append) {
|
||||
const incoming = Array.isArray(result?.items) ? result.items : [];
|
||||
if (append) {
|
||||
@@ -31,13 +43,31 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
};
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
if (result?.facets) onFacets({
|
||||
repositories: Array.isArray(result.facets.repositories) ? result.facets.repositories.slice() : [],
|
||||
labels: Array.isArray(result.facets.labels) ? result.facets.labels.slice() : [],
|
||||
});
|
||||
if (result?.facets && typeof document !== 'undefined') {
|
||||
facetView ||= attachFindWorkFacets(document.querySelector('#find-work-filters'), value =>
|
||||
String(value).replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character])),
|
||||
next => { filters = next; loadPage(1, false); });
|
||||
facetView(result.facets);
|
||||
}
|
||||
}
|
||||
|
||||
function loadPage(page, append) {
|
||||
if (loadRequest) return loadRequest;
|
||||
loadRequest = fetchJson('api/v1/available-issues?page=' + page, {
|
||||
function loadPage(page, append, requestedQuery = query) {
|
||||
if (loadRequest && requestedQuery === query && append) return loadRequest;
|
||||
const generation = ++loadGeneration;
|
||||
const params = new URLSearchParams();
|
||||
params.set('facets', 'true');
|
||||
if (requestedQuery) params.set('q', requestedQuery);
|
||||
filters.repositories.forEach(value => params.append('repository', value));
|
||||
filters.labels.forEach(value => params.append('label', value));
|
||||
const queryPart = params.toString() ? '&' + params.toString() : '';
|
||||
const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (generation !== loadGeneration) return result;
|
||||
apply(result, append);
|
||||
if (result?.refresh_failed === true) {
|
||||
onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
|
||||
@@ -45,8 +75,9 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
onStatus('Showing saved available work while the catalog refreshes…');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { loadRequest = null; });
|
||||
return loadRequest;
|
||||
}).finally(() => { if (loadRequest === request) loadRequest = null; });
|
||||
loadRequest = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -60,9 +91,66 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
if (!pagination.has_more) return Promise.resolve(false);
|
||||
return loadPage(pagination.page + 1, true);
|
||||
},
|
||||
search(value) {
|
||||
query = String(value || '').trim();
|
||||
return loadPage(1, false, query);
|
||||
},
|
||||
query() {
|
||||
return query;
|
||||
},
|
||||
setFilters(next) {
|
||||
filters = {
|
||||
repositories: Array.from(new Set((next?.repositories || []).map(String))).slice(0, 10),
|
||||
labels: Array.from(new Set((next?.labels || []).map(String))).slice(0, 10),
|
||||
};
|
||||
return loadPage(1, false);
|
||||
},
|
||||
filters() {
|
||||
return { repositories: filters.repositories.slice(), labels: filters.labels.slice() };
|
||||
},
|
||||
|
||||
items() {
|
||||
return available.slice();
|
||||
},
|
||||
startSelection() {
|
||||
selecting = true;
|
||||
selected.clear();
|
||||
return emitSelection();
|
||||
},
|
||||
cancelSelection() {
|
||||
selecting = false;
|
||||
selected.clear();
|
||||
return emitSelection();
|
||||
},
|
||||
toggleSelection(item) {
|
||||
if (!selecting || !item) return emitSelection();
|
||||
const key = itemKey(item);
|
||||
if (selected.has(key)) selected.delete(key);
|
||||
else selected.set(key, item);
|
||||
return emitSelection();
|
||||
},
|
||||
fillSelection(limit) {
|
||||
limit = Math.max(0, Math.floor(Number(limit) || 0));
|
||||
if (!limit) return { selected: selected.size, added: 0, limit };
|
||||
selecting = true;
|
||||
const before = selected.size;
|
||||
available.some(item => {
|
||||
if (selected.size >= limit) return true;
|
||||
selected.set(itemKey(item), item);
|
||||
return false;
|
||||
});
|
||||
emitSelection();
|
||||
return { selected: selected.size, added: selected.size - before, limit };
|
||||
},
|
||||
isSelected(item) {
|
||||
return selected.has(itemKey(item));
|
||||
},
|
||||
selectedItems() {
|
||||
return Array.from(selected.values());
|
||||
},
|
||||
selection() {
|
||||
return { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
|
||||
},
|
||||
togglePreview(item) {
|
||||
const key = itemKey(item);
|
||||
if (previewed.has(key)) previewed.delete(key);
|
||||
@@ -91,6 +179,8 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
pagination.total = Math.max(available.length, pagination.total - 1);
|
||||
pagination.has_more = available.length < pagination.total;
|
||||
previewed.delete(itemKey(item));
|
||||
selected.delete(itemKey(item));
|
||||
emitSelection();
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
onStatus('Assigned ' + key + ' to you.');
|
||||
@@ -101,4 +191,23 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
|
||||
};
|
||||
}
|
||||
|
||||
function attachFindWorkFacets(root, escapeHtml, onChange) {
|
||||
const values = name => Array.from(root.querySelectorAll('input[name="' + name + '"]:checked'), input => input.value);
|
||||
root.onchange = event => event.target.type === 'checkbox' && onChange({ repositories:values('find-work-repository'), labels:values('find-work-label') });
|
||||
root.querySelector('#clear-find-work-filters').onclick = () => {
|
||||
root.querySelectorAll('input').forEach(input => { input.checked = false; });
|
||||
onChange({ repositories:[], labels:[] });
|
||||
};
|
||||
return facets => {
|
||||
const active = { repositories:values('find-work-repository'), labels:values('find-work-label') };
|
||||
const render = (id, choices, selected, name) => {
|
||||
root.querySelector(id).innerHTML = choices.map(value => '<label class="find-work-filter-option"><input type="checkbox" name="' + name + '" value="' + escapeHtml(value) + '"' + (selected.includes(value) ? ' checked' : '') + ' />' + escapeHtml(value) + '</label>').join('');
|
||||
};
|
||||
render('#find-work-repository-filters', facets.repositories || [], active.repositories, 'find-work-repository');
|
||||
render('#find-work-label-filters', facets.labels || [], active.labels, 'find-work-label');
|
||||
const count = active.repositories.length + active.labels.length;
|
||||
root.querySelector('#find-work-filter-count').textContent = count ? '(' + count + ' active)' : '';
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
|
||||
@@ -0,0 +1,41 @@
|
||||
function createPlanTodayReadiness({ identity, inspect, concurrency = 3 }) {
|
||||
const workerCount = Math.max(1, Math.floor(Number(concurrency) || 1));
|
||||
let generation = 0;
|
||||
|
||||
async function run(items) {
|
||||
const runGeneration = ++generation;
|
||||
const candidates = Array.from(items || []);
|
||||
const results = new Array(candidates.length);
|
||||
let next = 0;
|
||||
|
||||
async function worker() {
|
||||
while (next < candidates.length) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
const item = candidates[index];
|
||||
let result;
|
||||
try {
|
||||
result = await inspect(item);
|
||||
} catch (_error) {
|
||||
result = { status:'unverified' };
|
||||
}
|
||||
results[index] = [identity(item), result];
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from(
|
||||
{ length:Math.min(workerCount, candidates.length) },
|
||||
() => worker(),
|
||||
));
|
||||
if (runGeneration !== generation) return null;
|
||||
return Object.fromEntries(results);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
generation += 1;
|
||||
}
|
||||
|
||||
return { run, cancel };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanTodayReadiness;
|
||||
+48
-4
@@ -7,6 +7,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
let recommendations = {};
|
||||
let recommendationAware = false;
|
||||
let capacityAware = false;
|
||||
let buildState = null;
|
||||
let buildReadiness = null;
|
||||
|
||||
function cleanItems(items) {
|
||||
const unique = new Map();
|
||||
@@ -33,6 +35,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
}
|
||||
recommendations = {};
|
||||
recommendationAware = actualMinutes !== null;
|
||||
buildState = null;
|
||||
buildReadiness = null;
|
||||
for (const [id, minutes] of Object.entries(actualMinutes || {})) {
|
||||
if (draftIds.includes(id) && Number.isInteger(minutes) && minutes >= 5 && minutes <= 1440 &&
|
||||
estimates[id] !== minutes) recommendations[id] = minutes;
|
||||
@@ -74,6 +78,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
recommendations = {};
|
||||
recommendationAware = false;
|
||||
capacityAware = false;
|
||||
buildState = null;
|
||||
buildReadiness = null;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
@@ -88,10 +94,12 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
}
|
||||
|
||||
function setEstimate(id, minutes) {
|
||||
if (!openState || !draftIds.includes(id)) return false;
|
||||
const resolvingBuildEstimate = buildState?.needs_estimate?.includes(id);
|
||||
if (!openState || (!draftIds.includes(id) && !resolvingBuildEstimate)) return false;
|
||||
capacityAware = true;
|
||||
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
|
||||
else delete estimates[id];
|
||||
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) return false;
|
||||
estimates[id] = minutes;
|
||||
if (resolvingBuildEstimate && buildReadiness) buildRecommendation(buildReadiness);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -104,6 +112,41 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRecommendation(readiness = {}) {
|
||||
if (!openState) return null;
|
||||
buildReadiness = { ...readiness };
|
||||
const selected = [];
|
||||
const reasons = {};
|
||||
const skipped = [];
|
||||
const needsEstimate = [];
|
||||
let used = 0;
|
||||
for (const [id, item] of itemsById) {
|
||||
if (selected.length >= limit) break;
|
||||
const check = readiness[id] || { status:'unverified' };
|
||||
if (check.status !== 'ready') {
|
||||
skipped.push({
|
||||
id,
|
||||
status:check.status === 'blocked' ? 'blocked' : 'unverified',
|
||||
reason:check.reason || (check.status === 'blocked' ? 'Blocked' : 'Could not verify readiness'),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const minutes = estimates[id];
|
||||
if (!Number.isInteger(minutes)) {
|
||||
needsEstimate.push(id);
|
||||
continue;
|
||||
}
|
||||
if (capacityMinutes !== null && used + minutes > capacityMinutes) continue;
|
||||
selected.push(id);
|
||||
reasons[id] = item.reason || item.attention_reason || 'Ranked My Work';
|
||||
used += minutes;
|
||||
}
|
||||
draftIds = selected;
|
||||
capacityAware = true;
|
||||
buildState = { selected:[...selected], reasons, skipped, needs_estimate:needsEstimate };
|
||||
return buildState;
|
||||
}
|
||||
|
||||
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
|
||||
if (!openState) return 'closed';
|
||||
const ids = [...draftIds];
|
||||
@@ -138,6 +181,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
remaining_minutes: remainingMinutes,
|
||||
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
|
||||
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
|
||||
...(buildState ? { build:buildState } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,7 +193,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
|
||||
}
|
||||
|
||||
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, commit, snapshot, item, candidates };
|
||||
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, buildRecommendation, commit, snapshot, item, candidates };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
|
||||
@@ -128,6 +128,7 @@
|
||||
}
|
||||
const labels = {
|
||||
sign_in: 'Signed in', sign_out: 'Signed out', passkey_enrolled: 'Passkey enrolled',
|
||||
passkey_counter_anomaly: 'Passkey counter anomaly',
|
||||
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
@@ -141,6 +142,9 @@
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
const context = [event.device_label, event.method, event.target,
|
||||
event.kind === 'passkey_counter_anomaly'
|
||||
? 'Remove and re-enroll this passkey if the alert repeats'
|
||||
: null,
|
||||
event.status === 'pending' ? 'Outcome confirmation pending' : null]
|
||||
.filter(value => typeof value === 'string' && value).join(' · ');
|
||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v93';
|
||||
const CACHE = 'stackchain-dashboard-shell-v98';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
@@ -21,6 +21,8 @@ const SHELL = [
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.js',
|
||||
BASE + 'static/draft-filing-session.js',
|
||||
BASE + 'static/draft-capacity-dialog.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
@@ -30,7 +32,9 @@ const SHELL = [
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/notification-undo.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/work-selection.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
BASE + 'static/today-recap.js',
|
||||
@@ -39,16 +43,19 @@ const SHELL = [
|
||||
BASE + 'static/comment-next.js',
|
||||
BASE + 'static/update-reply-read-next.js',
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-readiness.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/today-rollover.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
BASE + 'static/update-follow-up.js',
|
||||
BASE + 'static/later-work.js',
|
||||
BASE + 'static/later-sync.js',
|
||||
BASE + 'static/later-and-start.js',
|
||||
BASE + 'static/detail-defer.js',
|
||||
BASE + 'static/later-picker.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/batch-find-work.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/comment-actions.js',
|
||||
BASE + 'static/issue-attachment.js',
|
||||
@@ -64,6 +71,7 @@ const SHELL = [
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
@@ -73,6 +81,8 @@ const SHELL = [
|
||||
BASE + 'static/push-notifications.js',
|
||||
BASE + 'static/background-issue-sync.js',
|
||||
];
|
||||
const OPTIONAL_FEATURES = [
|
||||
];
|
||||
|
||||
async function fetchNavigation(request) {
|
||||
const controller = new AbortController();
|
||||
@@ -262,10 +272,16 @@ self.addEventListener('install', event => {
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(caches.keys().then(keys => Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
|
||||
.map(key => caches.delete(key))
|
||||
)).then(() => self.clients.claim()));
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
|
||||
.map(key => caches.delete(key))
|
||||
);
|
||||
const cache = await caches.open(CACHE);
|
||||
await Promise.allSettled(OPTIONAL_FEATURES.map(asset => cache.add(asset)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('sync', event => {
|
||||
@@ -433,5 +449,9 @@ self.addEventListener('fetch', event => {
|
||||
}
|
||||
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
return;
|
||||
}
|
||||
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
}
|
||||
});
|
||||
+16
-1
@@ -91,6 +91,21 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
return write(ids) ? 'added' : 'unavailable';
|
||||
}
|
||||
|
||||
function addMany(items) {
|
||||
if (!storageKey()) return { status: 'unavailable', ids: [] };
|
||||
const current = read();
|
||||
const additions = [];
|
||||
for (const item of items || []) {
|
||||
const id = identity(item);
|
||||
if (!id) return { status: 'unavailable', ids: [] };
|
||||
if (!current.includes(id) && !additions.includes(id)) additions.push(id);
|
||||
}
|
||||
if (!additions.length) return { status: 'exists', ids: [] };
|
||||
if (current.length + additions.length > limit) return { status: 'full', ids: [] };
|
||||
return write(current.concat(additions)) ?
|
||||
{ status: 'added', ids: additions } : { status: 'unavailable', ids: [] };
|
||||
}
|
||||
|
||||
function replace(ids) {
|
||||
const unique = [];
|
||||
for (const id of ids || []) {
|
||||
@@ -162,7 +177,7 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, read, replace, planning, replacePlanning, runway, add, remove, move, reconcile, contains, position, limit };
|
||||
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, move, reconcile, contains, position, limit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||
@@ -30,7 +30,12 @@ function createUnfiledCaptures({
|
||||
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
|
||||
}
|
||||
|
||||
function save(note) {
|
||||
function capacity() {
|
||||
const items = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
|
||||
return {full:items.length >= maxItems, count:items.length, maxItems, oldest:items.at(-1) || null};
|
||||
}
|
||||
|
||||
function prepare(note) {
|
||||
const title = String(note?.title || '').trim().slice(0, 255);
|
||||
const body = String(note?.body || '').trim().slice(0, 10000);
|
||||
if (!title) throw new Error('Add a title before saving.');
|
||||
@@ -43,42 +48,83 @@ function createUnfiledCaptures({
|
||||
if (hasAttachment && !attachmentStore) {
|
||||
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
|
||||
}
|
||||
return {title, body, ownerLogin, attachment, hasAttachment};
|
||||
}
|
||||
|
||||
function persist(prepared, existing, removed = null) {
|
||||
const item = {
|
||||
id:String(createId()), ownerLogin, title, body, savedAt:Number(now()),
|
||||
...(hasAttachment ? {hasAttachment:true} : {}),
|
||||
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
|
||||
savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
|
||||
};
|
||||
const existing = read().filter(candidate => candidate.id !== item.id);
|
||||
const items = [item, ...existing].slice(0, maxItems);
|
||||
const pruned = existing.filter(candidate => !items.some(retained => retained.id === candidate.id));
|
||||
if (!hasAttachment) {
|
||||
write(items);
|
||||
pruned.filter(candidate => candidate.hasAttachment).forEach(candidate =>
|
||||
Promise.resolve(attachmentStore?.delete(candidate.id)).catch(() => {}));
|
||||
return item;
|
||||
}
|
||||
return Promise.resolve(attachmentStore.put(item.id, {
|
||||
filename:String(attachment.filename).slice(0, 255),
|
||||
contentType:String(attachment.contentType), blob:attachment.blob,
|
||||
})).then(() => {
|
||||
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
|
||||
const writeItems = () => {
|
||||
try { write(items); }
|
||||
catch (error) {
|
||||
Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
pruned.filter(candidate => candidate.hasAttachment).forEach(candidate =>
|
||||
Promise.resolve(attachmentStore.delete(candidate.id)).catch(() => {}));
|
||||
return item;
|
||||
};
|
||||
const stage = prepared.hasAttachment
|
||||
? Promise.resolve(attachmentStore.put(item.id, {
|
||||
filename:String(prepared.attachment.filename).slice(0, 255),
|
||||
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
|
||||
}))
|
||||
: null;
|
||||
if (!stage && !removed?.hasAttachment) return writeItems();
|
||||
return Promise.resolve(stage).then(async () => {
|
||||
if (!removed?.hasAttachment) return writeItems();
|
||||
const removedAttachment = await attachmentStore.get(removed.id);
|
||||
try {
|
||||
await attachmentStore.delete(removed.id);
|
||||
} catch (error) {
|
||||
if (prepared.hasAttachment) await Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
try { return writeItems(); }
|
||||
catch (error) {
|
||||
if (removedAttachment) await Promise.resolve(attachmentStore.put(removed.id, removedAttachment)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function save(note) {
|
||||
const prepared = prepare(note);
|
||||
const existing = read();
|
||||
if (existing.length >= maxItems) throw new Error('Drafts full — nothing was deleted.');
|
||||
return persist(prepared, existing);
|
||||
}
|
||||
|
||||
function replaceOldest(note, expectedOldestId) {
|
||||
const prepared = prepare(note);
|
||||
const existing = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
|
||||
const oldest = existing.at(-1);
|
||||
if (existing.length < maxItems || !oldest || oldest.id !== expectedOldestId) {
|
||||
throw new Error('Drafts changed. Review them before replacing anything.');
|
||||
}
|
||||
return persist(prepared, existing.filter(item => item.id !== oldest.id), oldest);
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
const items = read();
|
||||
const remaining = items.filter(item => item.id !== id);
|
||||
if (remaining.length === items.length) return false;
|
||||
write(remaining);
|
||||
const removed = items.find(item => item.id === id);
|
||||
if (!removed?.hasAttachment) return true;
|
||||
return Promise.resolve(attachmentStore?.delete(id)).then(() => true);
|
||||
if (!removed?.hasAttachment) {
|
||||
write(remaining);
|
||||
return true;
|
||||
}
|
||||
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
|
||||
Promise.resolve(attachmentStore?.delete(id)).then(async () => {
|
||||
try { write(remaining); }
|
||||
catch (error) {
|
||||
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function resume(id, confirmedLogin) {
|
||||
@@ -100,7 +146,7 @@ function createUnfiledCaptures({
|
||||
|
||||
function completeResume(id) { return discard(id); }
|
||||
|
||||
return {list, save, discard, resume, completeResume};
|
||||
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||
@@ -0,0 +1,87 @@
|
||||
function createUpdateFollowUp({ storage = null, getLogin = () => '' } = {}) {
|
||||
const sourceKey = 'stackchain.update-follow-up-source.v1';
|
||||
const clean = (value, limit) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit);
|
||||
const safeRepository = value => {
|
||||
const candidate = String(value || '');
|
||||
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(candidate) &&
|
||||
candidate.split('/').every(part => part !== '.' && part !== '..') ? candidate : '';
|
||||
};
|
||||
const safeUrl = value => {
|
||||
try {
|
||||
const parsed = new URL(String(value || ''));
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : '';
|
||||
} catch (_error) { return ''; }
|
||||
};
|
||||
|
||||
function draft(detail = {}) {
|
||||
const source = safeUrl(detail.url || detail.latest_comment?.url);
|
||||
const context = clean(detail.latest_comment?.body || detail.subject_body, 9000);
|
||||
const title = clean(detail.title, 228);
|
||||
const sections = [];
|
||||
if (source) sections.push('Source: ' + source);
|
||||
if (context) sections.push('Latest context:\n> ' + context.replace(/\n/g, '\n> '));
|
||||
return {
|
||||
repository: safeRepository(detail.repository),
|
||||
title: ('Follow up: ' + (title || 'Unread update')).slice(0, 240),
|
||||
body: sections.join('\n\n').slice(0, 9500),
|
||||
};
|
||||
}
|
||||
|
||||
function readSource() {
|
||||
const login = String(getLogin() || '').trim();
|
||||
if (!login) return null;
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(sourceKey) || 'null');
|
||||
return record?.version === 1 && record.ownerLogin === login &&
|
||||
Number.isInteger(record.source?.notification_id) && record.source.notification_id > 0 ? record : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function writeSource(record) {
|
||||
storage?.setItem(sourceKey, JSON.stringify(record));
|
||||
}
|
||||
|
||||
function stageSource(item = {}) {
|
||||
const ownerLogin = String(getLogin() || '').trim();
|
||||
const notificationId = Number(item.notification_id);
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before continuing this follow-up.');
|
||||
if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid source update.');
|
||||
const source = {
|
||||
notification_id: notificationId,
|
||||
repository: safeRepository(item.repository),
|
||||
number: Number.isInteger(item.number) && item.number > 0 ? item.number : null,
|
||||
title: clean(item.title, 240), kind: 'update', has_update: true,
|
||||
};
|
||||
writeSource({ version:1, ownerLogin, source, admission:null });
|
||||
return { ...source };
|
||||
}
|
||||
|
||||
function source() {
|
||||
const record = readSource();
|
||||
return record ? { ...record.source } : null;
|
||||
}
|
||||
|
||||
function discardSource() {
|
||||
if (!readSource()) return false;
|
||||
storage?.removeItem(sourceKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function complete({ admit, queueRead, advance }) {
|
||||
const record = readSource();
|
||||
if (!record) throw new Error('Reopen the source update before continuing.');
|
||||
let admission = record.admission;
|
||||
if (!admission) {
|
||||
admission = await admit();
|
||||
writeSource({ ...record, admission });
|
||||
}
|
||||
await queueRead(record.source.notification_id);
|
||||
const advanced = await advance({ ...record.source });
|
||||
storage?.removeItem(sourceKey);
|
||||
return { admission, advanced:Boolean(advanced) };
|
||||
}
|
||||
|
||||
return { draft, stageSource, source, discardSource, complete };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateFollowUp;
|
||||
@@ -6,6 +6,7 @@
|
||||
'use strict';
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
const queueFilters = ['today', 'attention', 'update', 'later', 'draft'];
|
||||
|
||||
function positiveInteger(value) {
|
||||
const number = Number(value);
|
||||
@@ -15,6 +16,10 @@
|
||||
function parse(fragment) {
|
||||
const parts = String(fragment || '').split('/');
|
||||
if (parts[0] !== '#' || parts[1] !== 'my-work') return null;
|
||||
const queue = parts[2]?.replace(/s$/, '');
|
||||
if (queueFilters.includes(queue) && parts.length === 3) {
|
||||
return { kind: 'queue', filter: queue };
|
||||
}
|
||||
if (parts[2] === 'update' && parts.length === 4) {
|
||||
const notificationId = positiveInteger(parts[3]);
|
||||
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
||||
@@ -38,6 +43,7 @@
|
||||
}
|
||||
|
||||
function sameRoute(item, route) {
|
||||
if (route.kind === 'queue') return false;
|
||||
if (route.kind === 'update') {
|
||||
return Number(item.notification_id) === route.notification_id;
|
||||
}
|
||||
@@ -48,6 +54,7 @@
|
||||
|
||||
function createController({
|
||||
location, history, eventTarget, onOpen, onClose, onInvalid,
|
||||
onQueue = function () {},
|
||||
resolve, onResolving = function () {}, onError = function () {},
|
||||
}) {
|
||||
let items = [];
|
||||
@@ -100,6 +107,16 @@
|
||||
active = '';
|
||||
return;
|
||||
}
|
||||
if (route.kind === 'queue') {
|
||||
if (!ready) return;
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
if (active && active !== fragment) onClose();
|
||||
if (active === fragment) return;
|
||||
active = fragment;
|
||||
onQueue(route.filter);
|
||||
return;
|
||||
}
|
||||
const item = items.find(candidate => sameRoute(candidate, route));
|
||||
if (!item) {
|
||||
if (ready) resolveMissing(fragment, route);
|
||||
@@ -134,6 +151,15 @@
|
||||
onOpen(item);
|
||||
return true;
|
||||
},
|
||||
queue(filter) {
|
||||
if (!queueFilters.includes(filter)) return false;
|
||||
const name = filter + (['update', 'draft'].includes(filter) ? 's' : '');
|
||||
const fragment = '#/my-work/' + name;
|
||||
if (location.hash !== fragment) history.pushState(null, '', fragment);
|
||||
active = fragment;
|
||||
onQueue(filter);
|
||||
return true;
|
||||
},
|
||||
close() {
|
||||
if (parse(location.hash)) history.back();
|
||||
else {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||
let active = false;
|
||||
const selected = new Set();
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function changed() {
|
||||
const state = snapshot();
|
||||
onChange(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function start() {
|
||||
active = true;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
active = false;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function select(item) {
|
||||
if (!active) return 'inactive';
|
||||
const id = identity(item);
|
||||
if (!id) return 'invalid';
|
||||
if (selected.has(id)) return 'selected';
|
||||
if (selected.size >= maximum) return 'limit';
|
||||
selected.add(id);
|
||||
changed();
|
||||
return 'selected';
|
||||
}
|
||||
|
||||
function toggle(item) {
|
||||
if (!active) return 'inactive';
|
||||
const id = identity(item);
|
||||
if (!id) return 'invalid';
|
||||
if (selected.has(id)) {
|
||||
selected.delete(id);
|
||||
changed();
|
||||
return 'removed';
|
||||
}
|
||||
return select(item);
|
||||
}
|
||||
|
||||
function selectMany(items) {
|
||||
if (!active) return { status: 'inactive', added: 0, count: selected.size, limit: maximum };
|
||||
let added = 0;
|
||||
let capped = false;
|
||||
for (const item of items || []) {
|
||||
const id = identity(item);
|
||||
if (!id || selected.has(id)) continue;
|
||||
if (selected.size >= maximum) {
|
||||
capped = true;
|
||||
break;
|
||||
}
|
||||
selected.add(id);
|
||||
added += 1;
|
||||
}
|
||||
changed();
|
||||
return { status: capped ? 'limit' : 'selected', added, count: selected.size, limit: maximum };
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (!active) return snapshot();
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function retain(items) {
|
||||
const retained = new Set((items || []).map(identity).filter(Boolean));
|
||||
Array.from(selected).forEach(id => { if (!retained.has(id)) selected.delete(id); });
|
||||
return changed();
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { active, count: selected.size, ids: Array.from(selected) };
|
||||
}
|
||||
|
||||
return { identity, start, cancel, select, selectMany, clear, toggle, retain, snapshot, limit: maximum };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createWorkSelection;
|
||||
+15
-4
@@ -22,14 +22,16 @@ COMMONJS_BROWSER_BRANCH = re.compile(
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
||||
"issue-capture": ("static/create-issue-sheet.js",),
|
||||
"issue-capture": ("static/create-issue-sheet.js", "static/update-follow-up.js"),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/unfiled-captures.js",
|
||||
"static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
@@ -114,11 +116,20 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
for source in sources:
|
||||
if source != WORKER_RUNTIME_SOURCE:
|
||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
||||
eager_feature = feature_bundles["today-timer"]
|
||||
optional_features = {
|
||||
name: bundle for name, bundle in feature_bundles.items() if name != "today-timer"
|
||||
}
|
||||
worker = worker.replace(
|
||||
" BASE + 'static/dashboard.css',\n",
|
||||
" BASE + 'static/dashboard.css',\n"
|
||||
+ f" BASE + '{core.runtime_name}',\n"
|
||||
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in feature_bundles.values()),
|
||||
+ f" BASE + '{eager_feature.runtime_name}',\n",
|
||||
)
|
||||
worker = worker.replace(
|
||||
"const OPTIONAL_FEATURES = [\n",
|
||||
"const OPTIONAL_FEATURES = [\n"
|
||||
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
|
||||
)
|
||||
worker = CACHE_DECLARATION.sub(
|
||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
||||
|
||||
@@ -804,6 +804,14 @@ async def mark_notification_read(thread_id: int) -> None:
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def mark_notification_unread(thread_id: int) -> None:
|
||||
response = await _get_client().patch(
|
||||
f"/api/v1/notifications/threads/{thread_id}?to-status=unread",
|
||||
headers=_auth(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
async def acknowledge_notification(thread_id: int) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
|
||||
+88
-6
@@ -48,6 +48,7 @@ from src.live_snapshot_store import (
|
||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||
from src.passkey_store import PasskeyStore
|
||||
from src.push_notifications import PushConfiguration, dispatch_unread_updates
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||
@@ -1595,9 +1596,19 @@ async def verify_passkey_authorization(
|
||||
stored=stored,
|
||||
)
|
||||
updated = await asyncio.to_thread(
|
||||
store.update_counter, stored.credential_id, verified.new_sign_count
|
||||
store.advance_counter,
|
||||
stored.credential_id,
|
||||
expected=stored.sign_count,
|
||||
new=verified.new_sign_count,
|
||||
)
|
||||
if not updated:
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"passkey_counter_anomaly",
|
||||
method="passkey",
|
||||
device_label=stored.device_label,
|
||||
target=f"{payload.action}:{payload.target}",
|
||||
)
|
||||
raise ValueError("stale passkey counter")
|
||||
grant = await dashboard_auth.issue_step_up(
|
||||
request.state.dashboard_session,
|
||||
@@ -1686,9 +1697,19 @@ async def verify_passkey_authentication(
|
||||
stored=stored,
|
||||
)
|
||||
updated = await asyncio.to_thread(
|
||||
store.update_counter, stored.credential_id, verified.new_sign_count
|
||||
store.advance_counter,
|
||||
stored.credential_id,
|
||||
expected=stored.sign_count,
|
||||
new=verified.new_sign_count,
|
||||
)
|
||||
if not updated:
|
||||
await asyncio.to_thread(
|
||||
_security_event_store().record,
|
||||
"passkey_counter_anomaly",
|
||||
method="passkey",
|
||||
device_label=stored.device_label,
|
||||
target="sign_in:dashboard",
|
||||
)
|
||||
raise ValueError("stale passkey counter")
|
||||
await dashboard_auth.revoke_managed_session(stored.management_id)
|
||||
signed, session = await asyncio.to_thread(
|
||||
@@ -1788,6 +1809,10 @@ async def push_status(request: Request):
|
||||
async def subscribe_push(payload: PushSubscriptionPayload, request: Request):
|
||||
if not _push_configuration().enabled:
|
||||
raise HTTPException(status_code=503, detail="Push notifications are not configured")
|
||||
try:
|
||||
await validate_public_push_endpoint(payload.endpoint)
|
||||
except UnsafePushEndpoint as error:
|
||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
request.state.dashboard_session
|
||||
)
|
||||
@@ -2696,19 +2721,53 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
|
||||
raise
|
||||
|
||||
|
||||
def _available_issue_page(items: list[dict], page: int, limit: int = 50) -> dict:
|
||||
def _available_issue_page(
|
||||
items: list[dict], page: int, limit: int = 50, query: str = "",
|
||||
repositories: list[str] | None = None, labels: list[str] | None = None,
|
||||
include_facets: bool = False,
|
||||
) -> dict:
|
||||
facets = {
|
||||
"repositories": sorted({str(item.get("repository")) for item in items if item.get("repository")}, key=str.casefold),
|
||||
"labels": sorted({str(label) for item in items for label in (item.get("labels") or [])}, key=str.casefold),
|
||||
}
|
||||
repository_filter = {value.casefold() for value in (repositories or [])}
|
||||
label_filter = {value.casefold() for value in (labels or [])}
|
||||
if repository_filter:
|
||||
items = [item for item in items if str(item.get("repository") or "").casefold() in repository_filter]
|
||||
if label_filter:
|
||||
items = [item for item in items if label_filter & {str(label).casefold() for label in (item.get("labels") or [])}]
|
||||
normalized_query = query.strip().casefold()
|
||||
if normalized_query:
|
||||
number_query = normalized_query.removeprefix("#")
|
||||
items = [
|
||||
item for item in items
|
||||
if normalized_query in str(item.get("repository") or "").casefold()
|
||||
or normalized_query in str(item.get("title") or "").casefold()
|
||||
or (number_query.isdigit() and number_query == str(item.get("number") or ""))
|
||||
]
|
||||
start = (page - 1) * limit
|
||||
page_items = items[start:start + limit]
|
||||
return {
|
||||
result = {
|
||||
"items": page_items,
|
||||
"page": page,
|
||||
"total": len(items),
|
||||
"has_more": start + len(page_items) < len(items),
|
||||
}
|
||||
if include_facets or repository_filter or label_filter:
|
||||
result["facets"] = facets
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/v1/available-issues")
|
||||
async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONResponse:
|
||||
async def available_issues(
|
||||
page: int = Query(default=1, ge=1, le=100),
|
||||
q: str = Query(default="", max_length=100),
|
||||
repository: list[str] = Query(default=[], max_length=100),
|
||||
label: list[str] = Query(default=[], max_length=100),
|
||||
facets: bool = Query(default=False),
|
||||
) -> JSONResponse:
|
||||
if len(repository) > 10 or len(label) > 10:
|
||||
return JSONResponse({"error": "Select at most 10 repositories or labels."}, status_code=422)
|
||||
try:
|
||||
items, stale, revalidating, refresh_failed = await asyncio.wait_for(
|
||||
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
|
||||
@@ -2719,7 +2778,9 @@ async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONRe
|
||||
status_code=503,
|
||||
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
|
||||
)
|
||||
result = _available_issue_page(items, page)
|
||||
result = _available_issue_page(
|
||||
items, page, query=q, repositories=repository, labels=label, include_facets=facets
|
||||
)
|
||||
if stale:
|
||||
result["stale"] = True
|
||||
if revalidating:
|
||||
@@ -3432,6 +3493,27 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
return JSONResponse({"id": thread_id, "status": "read"})
|
||||
|
||||
|
||||
@app.patch("/api/v1/notifications/{thread_id}/unread")
|
||||
async def unread_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
gitea_proxy.mark_notification_unread(thread_id),
|
||||
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Restoring the update timed out. Retry Undo."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "The update could not be restored. Retry Undo."},
|
||||
status_code=503,
|
||||
)
|
||||
return JSONResponse({"id": thread_id, "status": "unread"})
|
||||
|
||||
|
||||
@app.patch("/api/v1/notifications/{thread_id}/later")
|
||||
async def defer_notification(
|
||||
payload: NotificationLaterRequest,
|
||||
|
||||
+17
-4
@@ -225,13 +225,26 @@ class PasskeyStore:
|
||||
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
|
||||
return StoredPasskey(*row) if row else None
|
||||
|
||||
def update_counter(self, credential_id: bytes, new_sign_count: int) -> bool:
|
||||
def advance_counter(
|
||||
self, credential_id: bytes, *, expected: int, new: int
|
||||
) -> bool:
|
||||
if expected == 0 and new == 0:
|
||||
comparison = "sign_count = 0"
|
||||
elif new > expected:
|
||||
comparison = "sign_count = ?"
|
||||
else:
|
||||
return False
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
parameters = (
|
||||
(new, credential_id)
|
||||
if expected == 0 and new == 0
|
||||
else (new, credential_id, expected)
|
||||
)
|
||||
cursor = connection.execute(
|
||||
"UPDATE passkey_credentials SET sign_count = ? "
|
||||
"WHERE credential_id = ? AND sign_count <= ?",
|
||||
(new_sign_count, credential_id, new_sign_count),
|
||||
f"UPDATE passkey_credentials SET sign_count = ? "
|
||||
f"WHERE credential_id = ? AND {comparison}",
|
||||
parameters,
|
||||
)
|
||||
return cursor.rowcount == 1
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from collections.abc import Awaitable, Callable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
class UnsafePushEndpoint(ValueError):
|
||||
"""Raised when a Web Push endpoint could reach a non-public service."""
|
||||
|
||||
|
||||
Resolver = Callable[[str, int], Awaitable[list[str]]]
|
||||
|
||||
|
||||
async def _resolve(host: str, port: int) -> list[str]:
|
||||
loop = asyncio.get_running_loop()
|
||||
records = await loop.getaddrinfo(
|
||||
host,
|
||||
port,
|
||||
family=socket.AF_UNSPEC,
|
||||
type=socket.SOCK_STREAM,
|
||||
)
|
||||
return sorted({record[4][0] for record in records})
|
||||
|
||||
|
||||
async def validate_public_push_endpoint(
|
||||
endpoint: str,
|
||||
*,
|
||||
resolver: Resolver = _resolve,
|
||||
timeout_seconds: float = 2.0,
|
||||
) -> str:
|
||||
"""Return a canonical public HTTPS push endpoint or fail closed."""
|
||||
parsed = urlsplit(endpoint)
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as error:
|
||||
raise UnsafePushEndpoint(
|
||||
"Endpoint must be a canonical public Web Push service URL"
|
||||
) from error
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.fragment
|
||||
or port not in {None, 443}
|
||||
):
|
||||
raise UnsafePushEndpoint("Endpoint must be a canonical public Web Push service URL")
|
||||
try:
|
||||
addresses = await asyncio.wait_for(
|
||||
resolver(parsed.hostname, port or 443),
|
||||
timeout=max(0.01, timeout_seconds),
|
||||
)
|
||||
except (OSError, asyncio.TimeoutError) as error:
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
|
||||
try:
|
||||
public = addresses and all(ipaddress.ip_address(address).is_global for address in addresses)
|
||||
except ValueError as error:
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
|
||||
if not public:
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
|
||||
return endpoint
|
||||
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -22,8 +23,12 @@ class PushConfiguration:
|
||||
async def send_web_push(
|
||||
subscription: dict, payload: str, configuration: PushConfiguration
|
||||
) -> None:
|
||||
import requests
|
||||
from pywebpush import webpush
|
||||
|
||||
session = requests.Session()
|
||||
session.max_redirects = 0
|
||||
|
||||
await asyncio.to_thread(
|
||||
webpush,
|
||||
subscription_info=subscription,
|
||||
@@ -31,6 +36,8 @@ async def send_web_push(
|
||||
vapid_private_key=configuration.private_key,
|
||||
vapid_claims={"sub": configuration.subject},
|
||||
ttl=300,
|
||||
timeout=10,
|
||||
requests_session=session,
|
||||
)
|
||||
|
||||
|
||||
@@ -45,6 +52,7 @@ async def dispatch_unread_updates(
|
||||
send_timeout_seconds: float = 10.0,
|
||||
max_concurrency: int = 8,
|
||||
max_individual_notifications: int = 3,
|
||||
endpoint_validator: Callable[[str], Awaitable[str]] | None = None,
|
||||
) -> int:
|
||||
if not configuration.enabled:
|
||||
return 0
|
||||
@@ -90,6 +98,15 @@ async def dispatch_unread_updates(
|
||||
async with semaphore:
|
||||
if ownership_lost.is_set():
|
||||
return 0
|
||||
validate_endpoint = endpoint_validator
|
||||
if validate_endpoint is None and send is None:
|
||||
validate_endpoint = validate_public_push_endpoint
|
||||
try:
|
||||
if validate_endpoint is not None:
|
||||
await validate_endpoint(delivery.subscription["endpoint"])
|
||||
except UnsafePushEndpoint:
|
||||
await asyncio.to_thread(store.delete_session, delivery.session_id)
|
||||
return 0
|
||||
count = 0
|
||||
digest_pending = set(delivery.digest_revisions)
|
||||
new_revisions = tuple(
|
||||
@@ -202,8 +219,17 @@ async def dispatch_unread_updates(
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def dispatch_device_safely(delivery) -> int:
|
||||
try:
|
||||
return await dispatch_device(delivery)
|
||||
except Exception:
|
||||
# Device-specific validation, persistence, or provider failures
|
||||
# must not cancel healthy siblings. Leave any uncheckpointed
|
||||
# revisions unseen so a later poll can retry them.
|
||||
return 0
|
||||
|
||||
counts = await asyncio.gather(
|
||||
*(dispatch_device(delivery) for delivery in deliveries)
|
||||
*(dispatch_device_safely(delivery) for delivery in deliveries)
|
||||
)
|
||||
return sum(counts)
|
||||
finally:
|
||||
|
||||
@@ -1420,5 +1420,6 @@ async def test_dashboard_offers_explicit_account_bound_delivery_receipt_opt_in()
|
||||
assert "await Notification.requestPermission()" in html
|
||||
assert "backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled)" in html
|
||||
assert "await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin)" in html
|
||||
assert "window.addEventListener('hashchange', openDeliveryReceiptRoute);" in html
|
||||
assert "if (window.location.hash !== '#/my-work/drafts') return;" in html
|
||||
assert "onQueue: openWorkQueueRoute" in html
|
||||
assert "workRoute.queue(filter)" in html
|
||||
assert "openDeliveryReceiptRoute" not in html
|
||||
@@ -0,0 +1,488 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BATCH_FIND_WORK = Path(__file__).parents[1] / "frontend" / "batch-find-work.js"
|
||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_batch_preflights_today_capacity_before_claiming_any_issue():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>1,
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
onProgress:progress=>calls.push('progress:'+progress.status),
|
||||
}});
|
||||
flow.run([
|
||||
{{repository:'stackchain/dashboard',number:671}},
|
||||
{{repository:'stackchain/dashboard',number:672}},
|
||||
]).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "full",
|
||||
"selected": 2,
|
||||
"available": 1,
|
||||
"queued": [],
|
||||
"failed": [],
|
||||
},
|
||||
"calls": ["progress:full"],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_claims_in_order_continues_after_conflict_and_reports_truthfully():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
claim:item=>{{
|
||||
calls.push('claim:'+item.number);
|
||||
return item.number===672 ? Promise.reject(new Error('already claimed')) :
|
||||
Promise.resolve({{...item,assignees:['timmy']}});
|
||||
}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
onProgress:progress=>calls.push('progress:'+progress.status+':'+progress.processed),
|
||||
}});
|
||||
flow.run([671,672,673].map(number=>({{repository:'stackchain/dashboard',number}})))
|
||||
.then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete",
|
||||
"selected": 3,
|
||||
"available": 3,
|
||||
"queued": ["stackchain/dashboard#671", "stackchain/dashboard#673"],
|
||||
"failed": [{"key": "stackchain/dashboard#672", "reason": "already claimed"}],
|
||||
},
|
||||
"calls": [
|
||||
"claim:671", "queue:671", "progress:running:1",
|
||||
"claim:672", "progress:running:2",
|
||||
"claim:673", "queue:673", "progress:running:3",
|
||||
"progress:complete:3",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_does_not_reclaim_confirmed_issue_when_today_sync_needs_recovery():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>1,
|
||||
claim:item=>{{calls.push('claim');return Promise.resolve({{...item,assignees:['timmy']}});}},
|
||||
queue:item=>{{calls.push('queue');return 'sync-unavailable';}},
|
||||
}});
|
||||
flow.run([{{repository:'stackchain/dashboard',number:671}}])
|
||||
.then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete",
|
||||
"selected": 1,
|
||||
"available": 1,
|
||||
"queued": [],
|
||||
"failed": [{
|
||||
"key": "stackchain/dashboard#671",
|
||||
"reason": "assigned but Today sync is unavailable",
|
||||
"assigned": True,
|
||||
}],
|
||||
},
|
||||
"calls": ["claim", "queue"],
|
||||
}
|
||||
|
||||
|
||||
def test_interrupted_batch_resumes_confirmed_assignment_without_claiming_twice():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const values={{}};
|
||||
const storage={{
|
||||
getItem:key=>values[key] || null,
|
||||
setItem:(key,value)=>{{values[key]=value;}},
|
||||
removeItem:key=>{{delete values[key];}},
|
||||
}};
|
||||
const calls=[];
|
||||
const item={{repository:'stackchain/dashboard',number:679,title:'Recover me'}};
|
||||
const first=createBatchFindWork({{
|
||||
capacity:()=>1, storage, owner:()=> 'timmy',
|
||||
claim:value=>{{calls.push('claim');return Promise.resolve({{...value,assignees:['timmy']}});}},
|
||||
queue:()=>{{calls.push('queue-failed');return 'sync-unavailable';}},
|
||||
}});
|
||||
first.run([item]).then(firstResult=>{{
|
||||
const second=createBatchFindWork({{
|
||||
capacity:()=>1, storage, owner:()=> 'timmy',
|
||||
claim:()=>{{calls.push('duplicate-claim');return Promise.resolve(item);}},
|
||||
queue:value=>{{calls.push('queue-resumed:'+value.assignees[0]);return 'queued';}},
|
||||
}});
|
||||
return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{
|
||||
firstResult, resumed, calls, keys:Object.keys(values),
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"firstResult": {
|
||||
"status": "complete", "selected": 1, "available": 1, "queued": [],
|
||||
"failed": [{
|
||||
"key": "stackchain/dashboard#679",
|
||||
"reason": "assigned but Today sync is unavailable",
|
||||
"assigned": True,
|
||||
}],
|
||||
},
|
||||
"resumed": {
|
||||
"status": "complete", "selected": 1, "available": 1,
|
||||
"queued": ["stackchain/dashboard#679"], "failed": [],
|
||||
},
|
||||
"calls": ["claim", "queue-failed", "queue-resumed:timmy"],
|
||||
"keys": [],
|
||||
}
|
||||
|
||||
|
||||
def test_resumed_batch_skips_queued_items_and_continues_in_original_order():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const key='stackchain.find-work-batch.v1.timmy';
|
||||
const values={{[key]:JSON.stringify({{
|
||||
owner:'timmy', available:2, items:[
|
||||
{{item:{{repository:'stackchain/dashboard',number:678}},state:'queued'}},
|
||||
{{item:{{repository:'stackchain/dashboard',number:679}},state:'pending'}},
|
||||
],
|
||||
}})}};
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>2, owner:()=> 'timmy',
|
||||
storage:{{getItem:key=>values[key]||null,setItem:(key,value)=>values[key]=value,removeItem:key=>delete values[key]}},
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
}});
|
||||
flow.resume().then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete", "selected": 2, "available": 2,
|
||||
"queued": ["stackchain/dashboard#678", "stackchain/dashboard#679"],
|
||||
"failed": [],
|
||||
},
|
||||
"calls": ["claim:679", "queue:679"],
|
||||
}
|
||||
|
||||
|
||||
def test_time_budget_blocks_claims_until_every_estimate_fits():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
timeBudget:()=>({{capacity_minutes:90,planned_minutes:30}}),
|
||||
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
}});
|
||||
const items=[701,702].map(number=>({{repository:'stackchain/dashboard',number}}));
|
||||
Promise.all([
|
||||
flow.run(items, {{'stackchain/dashboard#701':30}}),
|
||||
flow.run(items, {{'stackchain/dashboard#701':30,'stackchain/dashboard#702':45}}),
|
||||
]).then(results=>process.stdout.write(JSON.stringify({{results,calls}})));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"results": [
|
||||
{
|
||||
"status": "estimates-required", "selected": 2, "available": 3,
|
||||
"queued": [], "failed": [], "remaining_minutes": 60,
|
||||
"requested_minutes": 30,
|
||||
"invalid": ["stackchain/dashboard#702"],
|
||||
},
|
||||
{
|
||||
"status": "over-budget", "selected": 2, "available": 3,
|
||||
"queued": [], "failed": [], "remaining_minutes": 60,
|
||||
"requested_minutes": 75, "over_minutes": 15,
|
||||
},
|
||||
],
|
||||
"calls": [],
|
||||
}
|
||||
|
||||
|
||||
def test_time_budget_persists_estimates_only_for_successfully_queued_claims():
|
||||
script = f"""
|
||||
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
|
||||
const calls=[];
|
||||
const flow=createBatchFindWork({{
|
||||
capacity:()=>3,
|
||||
timeBudget:()=>({{capacity_minutes:120,planned_minutes:30}}),
|
||||
claim:item=>item.number===702 ? Promise.reject(new Error('already claimed')) : Promise.resolve(item),
|
||||
queue:item=>'queued',
|
||||
persistEstimate:(item,minutes)=>calls.push(item.number+':'+minutes),
|
||||
}});
|
||||
flow.run([701,702].map(number=>({{repository:'stackchain/dashboard',number}})),{{
|
||||
'stackchain/dashboard#701':30,
|
||||
'stackchain/dashboard#702':45,
|
||||
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"status": "complete", "selected": 2, "available": 3,
|
||||
"queued": ["stackchain/dashboard#701"],
|
||||
"failed": [{"key": "stackchain/dashboard#702", "reason": "already claimed"}],
|
||||
},
|
||||
"calls": ["701:30"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_has_preclaim_time_review_controls():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-estimate-review"' in html
|
||||
assert 'id="find-work-estimate-list"' in html
|
||||
assert 'id="confirm-find-work-estimates"' in html
|
||||
assert 'inputmode="numeric"' in dashboard
|
||||
assert "timeBudget:" in dashboard
|
||||
assert "persistEstimate:" in dashboard
|
||||
assert ".find-work-estimate-review" in css
|
||||
assert ".find-work-estimate-review input" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_selection_survives_loaded_pages_and_removes_confirmed_claims():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const pages={{
|
||||
1:{{items:[{{id:1,repository:'stackchain/dashboard',number:671}}],page:1,total:2,has_more:true}},
|
||||
2:{{items:[{{id:2,repository:'stackchain/dashboard',number:672}}],page:2,total:2,has_more:false}},
|
||||
}};
|
||||
const controller=createFindWork({{
|
||||
fetchJson:path=>path.includes('/claim') ? Promise.resolve({{repository:'stackchain/dashboard',number:671,assignees:['timmy']}}) :
|
||||
Promise.resolve(pages[path.includes('page=2') ? 2 : 1]),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
return controller.loadMore();
|
||||
}}).then(()=>{{
|
||||
controller.toggleSelection(controller.items()[1]);
|
||||
return controller.claim(controller.items()[0]);
|
||||
}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
selecting:controller.selection().active,
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"selected": [672], "selecting": True}
|
||||
|
||||
|
||||
def test_fill_today_selects_ranked_matches_up_to_remaining_capacity():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const states=[];
|
||||
const controller=createFindWork({{
|
||||
fetchJson:()=>Promise.resolve({{items:[
|
||||
{{id:1,repository:'stackchain/dashboard',number:701}},
|
||||
{{id:2,repository:'stackchain/dashboard',number:702}},
|
||||
{{id:3,repository:'stackchain/dashboard',number:703}},
|
||||
{{id:4,repository:'stackchain/dashboard',number:704}},
|
||||
],page:1,total:4,has_more:false}}),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}},
|
||||
onSelection:state=>states.push(state),
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[1]);
|
||||
const outcome=controller.fillSelection(3);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
outcome,
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
lastState:states.at(-1),
|
||||
}}));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"outcome": {"selected": 3, "added": 2, "limit": 3},
|
||||
"selected": [702, 701, 703],
|
||||
"lastState": {
|
||||
"active": True,
|
||||
"count": 3,
|
||||
"ids": [
|
||||
"stackchain/dashboard#702",
|
||||
"stackchain/dashboard#701",
|
||||
"stackchain/dashboard#703",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_fill_today_with_no_capacity_does_not_change_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const controller=createFindWork({{
|
||||
fetchJson:()=>Promise.resolve({{items:[{{id:1,repository:'stackchain/dashboard',number:701}}]}}),
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
controller.load().then(()=>{{
|
||||
const outcome=controller.fillSelection(0);
|
||||
process.stdout.write(JSON.stringify({{outcome,selection:controller.selection()}}));
|
||||
}});
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"outcome": {"selected": 0, "added": 0, "limit": 0},
|
||||
"selection": {"active": False, "count": 0, "ids": []},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert 'id="select-find-work"' in html
|
||||
assert 'id="batch-find-work-actions"' in html
|
||||
assert 'id="claim-selected-work"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "createBatchFindWork({" in dashboard
|
||||
assert "findWorkController.startSelection()" in dashboard
|
||||
assert "findWorkController.toggleSelection(item)" in dashboard
|
||||
assert ".find-work-batch-actions" in css
|
||||
assert "position:sticky" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert "BASE + 'static/batch-find-work.js'" in worker
|
||||
assert '<script src="static/batch-find-work.js"></script>' in html
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_touch_accessible_batch_recovery():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="resume-find-work-batch"' in html
|
||||
assert 'aria-describedby="find-work-status"' in html
|
||||
assert "mountRecovery(" in BATCH_FIND_WORK.read_text()
|
||||
assert "typeof localStorage === 'undefined'" in BATCH_FIND_WORK.read_text()
|
||||
assert ".find-work-batch-recovery" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="fill-find-work-today"' in html
|
||||
assert 'aria-describedby="find-work-selection-status"' in html
|
||||
assert "findWorkController.fillSelection(remainingSlots)" in dashboard
|
||||
assert "openFindWorkEstimateReview(findWorkController.selectedItems())" in dashboard
|
||||
assert ".fill-find-work-today" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_search_ignores_stale_response_and_preserves_cross_query_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const pending=[];
|
||||
const rendered=[];
|
||||
const fetchJson=path=>new Promise(resolve=>pending.push({{path,resolve}}));
|
||||
const controller=createFindWork({{
|
||||
fetchJson,
|
||||
onItems:items=>rendered.push(items.map(item=>item.number)),
|
||||
onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
|
||||
}});
|
||||
const first=controller.search('api');
|
||||
const second=controller.search('dashboard');
|
||||
pending[1].resolve({{items:[{{id:2,repository:'stackchain/dashboard',number:673}}],page:1,total:1,has_more:false}});
|
||||
second.then(()=>{{
|
||||
controller.startSelection();
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
const third=controller.search('worker');
|
||||
pending[2].resolve({{items:[{{id:3,repository:'stackchain/worker',number:674}}],page:1,total:1,has_more:false}});
|
||||
return third;
|
||||
}}).then(()=>{{
|
||||
controller.toggleSelection(controller.items()[0]);
|
||||
pending[0].resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false}});
|
||||
return first;
|
||||
}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
calls:pending.map(entry=>entry.path),
|
||||
rendered,
|
||||
items:controller.items().map(item=>item.number),
|
||||
selected:controller.selectedItems().map(item=>item.number),
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": [
|
||||
"api/v1/available-issues?page=1&facets=true&q=api",
|
||||
"api/v1/available-issues?page=1&facets=true&q=dashboard",
|
||||
"api/v1/available-issues?page=1&facets=true&q=worker",
|
||||
],
|
||||
"rendered": [[673], [674]],
|
||||
"items": [674],
|
||||
"selected": [673, 674],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_search_has_clear_live_results_and_touch_targets():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-search"' in html
|
||||
assert 'id="clear-find-work-search"' in html
|
||||
assert 'id="find-work-match-status"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert "findWorkController.search(" in dashboard
|
||||
assert "#find-work-search" in dashboard
|
||||
assert ".find-work-search" in css
|
||||
assert ".find-work-search button" in css and "min-height:44px" in css
|
||||
|
||||
|
||||
def test_find_work_facets_serialize_filters_and_preserve_selection():
|
||||
script = f"""
|
||||
const createFindWork=require({json.dumps(str(PICK_WORK))});
|
||||
const calls=[];
|
||||
const controller=createFindWork({{
|
||||
fetchJson:path=>{{ calls.push(path); return Promise.resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false,facets:{{repositories:['stackchain/api'],labels:['critical']}}}}); }},
|
||||
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}}, onFacets:()=>{{}},
|
||||
}});
|
||||
controller.reset({{items:[{{id:9,repository:'stackchain/old',number:9}}],page:1,total:1,has_more:false}});
|
||||
controller.startSelection(); controller.toggleSelection(controller.items()[0]);
|
||||
controller.setFilters({{repositories:['stackchain/api'],labels:['critical']}}).then(()=>process.stdout.write(JSON.stringify({{
|
||||
calls, filters:controller.filters(), selected:controller.selectedItems().map(item=>item.number)
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": ["api/v1/available-issues?page=1&facets=true&repository=stackchain%2Fapi&label=critical"],
|
||||
"filters": {"repositories": ["stackchain/api"], "labels": ["critical"]},
|
||||
"selected": [9],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_find_work_exposes_accessible_touch_sized_facets():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert 'id="find-work-filters"' in html
|
||||
assert 'id="find-work-repository-filters"' in html
|
||||
assert 'id="find-work-label-filters"' in html
|
||||
assert 'id="clear-find-work-filters"' in html
|
||||
assert 'aria-label="Filter available work"' in html
|
||||
assert "setFilters(next)" in PICK_WORK.read_text()
|
||||
assert ".find-work-filters" in css
|
||||
assert ".find-work-filter-option" in css and "min-height:44px" in css
|
||||
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v93" in worker
|
||||
assert "stackchain-dashboard-shell-v98" in worker
|
||||
@@ -170,6 +170,102 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
|
||||
assert "correct horse battery staple" not in signed_in.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stale_nonzero_passkey_counter_denies_sign_in_and_records_anomaly(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class StaleAuthentication:
|
||||
new_sign_count = 4
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys,
|
||||
"verify_authentication",
|
||||
lambda **_kwargs: StaleAuthentication(),
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
options = await client.post("/api/v1/passkeys/authentication/options")
|
||||
denied = await client.post(
|
||||
"/api/v1/passkeys/authentication/verify",
|
||||
json={
|
||||
"challenge": options.json()["challenge"],
|
||||
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
|
||||
"device_label": "Phone",
|
||||
"action": "sign_in",
|
||||
"target": "dashboard",
|
||||
},
|
||||
)
|
||||
|
||||
events = main._security_event_store().list(limit=10).events
|
||||
assert denied.status_code == 401
|
||||
assert "stackchain_session=" not in denied.headers.get("set-cookie", "")
|
||||
assert [event.kind for event in events] == ["passkey_counter_anomaly"]
|
||||
assert events[0].device_label == "Phone"
|
||||
assert events[0].target == "sign_in:dashboard"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stale_nonzero_passkey_counter_denies_fresh_authorization(
|
||||
access_control, monkeypatch
|
||||
):
|
||||
class StaleAuthentication:
|
||||
new_sign_count = 4
|
||||
|
||||
monkeypatch.setattr(
|
||||
main.passkeys,
|
||||
"verify_authentication",
|
||||
lambda **_kwargs: StaleAuthentication(),
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
main._passkey_store().register,
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
|
||||
)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
options = await client.post(
|
||||
"/api/v1/passkeys/authorization/options",
|
||||
json={"action": "merge_pull", "target": "stackchain/dashboard#42"},
|
||||
headers=headers,
|
||||
)
|
||||
denied = await client.post(
|
||||
"/api/v1/passkeys/authorization/verify",
|
||||
json={
|
||||
"challenge": options.json()["challenge"],
|
||||
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
|
||||
"action": "merge_pull",
|
||||
"target": "stackchain/dashboard#42",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
events = main._security_event_store().list(limit=10).events
|
||||
assert options.status_code == 200
|
||||
assert denied.status_code == 401
|
||||
assert "grant" not in denied.json()
|
||||
assert events[0].kind == "passkey_counter_anomaly"
|
||||
assert events[0].target == "merge_pull:stackchain/dashboard#42"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_passkey_options_are_source_limited_before_challenge_generation(
|
||||
access_control, monkeypatch
|
||||
|
||||
@@ -313,7 +313,7 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
|
||||
assert 'Sending <strong data-delivery-count="sending">' in html
|
||||
assert 'Needs attention <strong data-delivery-count="attention">' in html
|
||||
assert 'Authorize <strong data-delivery-count="authorization">' in html
|
||||
assert "const deliveryCenter = draftInbox.partition(lastDrafts);" in html
|
||||
assert "const deliveryCenter = draftInbox.partition(displayedDrafts);" in html
|
||||
assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html
|
||||
assert "deliveryCenter.retryable.length" in html
|
||||
assert "item.status === 'sending' ? 'Sending'" in html
|
||||
|
||||
@@ -69,7 +69,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||
assert b"gitea_time_logged" in security_center.runtime_bytes
|
||||
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
|
||||
assert len(first.runtime_gzip_bytes) <= 95 * 1024
|
||||
assert len(first.runtime_gzip_bytes) <= 96 * 1024
|
||||
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
||||
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||
@@ -77,6 +77,18 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source
|
||||
|
||||
shell_block, optional_block = first.service_worker_source.split(
|
||||
"const OPTIONAL_FEATURES = [", 1
|
||||
)
|
||||
optional_block = optional_block.split("];", 1)[0]
|
||||
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" in shell_block
|
||||
for name, bundle in first.feature_bundles.items():
|
||||
if name == "today-timer":
|
||||
assert f"BASE + '{bundle.runtime_name}'" not in optional_block
|
||||
else:
|
||||
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
|
||||
assert f"BASE + '{bundle.runtime_name}'" in optional_block
|
||||
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
capture_source = changed_frontend / "create-issue-sheet.js"
|
||||
@@ -175,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v93';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v98';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -215,6 +215,120 @@ async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkey
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_search_filters_full_catalog_before_pagination(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{
|
||||
"repository": "stackchain/api",
|
||||
"number": number,
|
||||
"title": "Routine API maintenance",
|
||||
}
|
||||
for number in range(1, 52)
|
||||
] + [{
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 673,
|
||||
"title": "Search the full Find Work catalog",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
by_repo = await client.get("/api/v1/available-issues?page=1&q=DASHBOARD")
|
||||
by_number = await client.get("/api/v1/available-issues?page=1&q=%23673")
|
||||
by_title = await client.get("/api/v1/available-issues?page=1&q=full%20find")
|
||||
|
||||
expected = [{
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 673,
|
||||
"title": "Search the full Find Work catalog",
|
||||
}]
|
||||
assert by_repo.json() == {"items": expected, "page": 1, "total": 1, "has_more": False}
|
||||
assert by_number.json() == by_repo.json()
|
||||
assert by_title.json() == by_repo.json()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_filter_before_pagination_and_describe_catalog(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]},
|
||||
{"repository": "stackchain/api", "number": 2, "title": "API docs", "labels": ["docs"]},
|
||||
{"repository": "stackchain/dashboard", "number": 3, "title": "Critical UI", "labels": ["critical", "frontend"]},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("repository", "stackchain/api"), ("label", "critical")],
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"items": [{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]}],
|
||||
"page": 1,
|
||||
"total": 1,
|
||||
"has_more": False,
|
||||
"facets": {
|
||||
"repositories": ["stackchain/api", "stackchain/dashboard"],
|
||||
"labels": ["backend", "critical", "docs", "frontend"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_or_within_each_facet_and_compose_with_search(monkeypatch):
|
||||
async def available():
|
||||
return [
|
||||
{"repository": "stackchain/api", "number": 1, "title": "Repair worker", "labels": ["backend"]},
|
||||
{"repository": "stackchain/web", "number": 2, "title": "Repair mobile", "labels": ["frontend"]},
|
||||
{"repository": "stackchain/docs", "number": 3, "title": "Repair guide", "labels": ["docs"]},
|
||||
{"repository": "stackchain/api", "number": 4, "title": "Routine task", "labels": ["backend"]},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("q", "repair"), ("repository", "stackchain/api"), ("repository", "stackchain/web"),
|
||||
("label", "backend"), ("label", "frontend")],
|
||||
)
|
||||
|
||||
assert [item["number"] for item in response.json()["items"]] == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_facets_reject_excess_values_before_scanning(monkeypatch):
|
||||
async def must_not_scan():
|
||||
raise AssertionError("invalid facets must be rejected before catalog scan")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/available-issues",
|
||||
params=[("label", f"label-{index}") for index in range(11)],
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_search_rejects_oversized_query_without_scanning(monkeypatch):
|
||||
async def must_not_scan():
|
||||
raise AssertionError("invalid query must be rejected before catalog scan")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/available-issues", params={"q": "x" * 101})
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_available_issue_endpoint_reuses_catalog_published_by_another_worker(
|
||||
monkeypatch, tmp_path
|
||||
|
||||
@@ -744,3 +744,55 @@ async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actio
|
||||
assert 'Needs attention' in html
|
||||
assert "issueOutbox.discard(item.outbox_id)" in html
|
||||
assert '.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));' in html
|
||||
|
||||
|
||||
|
||||
def test_issue_outbox_reuses_the_original_operation_when_a_source_draft_is_retried():
|
||||
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)}};
|
||||
let sequence = 0;
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'draft-op-' + (++sequence),
|
||||
}});
|
||||
const first = outbox.enqueue({{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-7'}});
|
||||
const retried = outbox.enqueue({{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-7'}});
|
||||
process.stdout.write(JSON.stringify({{first,retried,items:outbox.list(),sequence}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["sequence"] == 1
|
||||
assert len(output["items"]) == 1
|
||||
assert output["first"]["id"] == output["retried"]["id"] == "draft-op-1"
|
||||
assert output["items"][0]["operationId"] == "draft-op-1"
|
||||
assert output["items"][0]["sourceCaptureId"] == "capture-7"
|
||||
|
||||
|
||||
def test_durable_source_draft_retry_reconciles_without_allocating_another_operation():
|
||||
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)}};
|
||||
let sequence = 0; const reconciliations = []; let syncRequests = 0;
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'durable-' + (++sequence),
|
||||
backgroundSync:{{
|
||||
reconcile:async items=>reconciliations.push(items.map(item=>item.operationId)),
|
||||
requestSync:async()=>{{ syncRequests += 1; }},
|
||||
}},
|
||||
}});
|
||||
(async()=>{{
|
||||
const draft={{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-8'}};
|
||||
const first=await outbox.enqueueDurably(draft);
|
||||
const retried=await outbox.enqueueDurably(draft);
|
||||
process.stdout.write(JSON.stringify({{first,retried,reconciliations,syncRequests,items:outbox.list()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert "reused" not in output["first"]
|
||||
assert output["retried"]["reused"] is True
|
||||
assert output["reconciliations"] == [["durable-1"], ["durable-1"]]
|
||||
assert output["syncRequests"] == 2
|
||||
assert len(output["items"]) == 1
|
||||
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "stackchain-dashboard-shell-v98" 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 pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v93" in worker
|
||||
assert "stackchain-dashboard-shell-v98" in worker
|
||||
@@ -20,6 +20,7 @@ def test_all_text_entry_flows_are_wired_to_keyboard_safe_panels():
|
||||
("#issue-sheet .issue-sheet-panel", ".issue-comment-composer", "#issue-comment", "#send-issue-comment", "#issue-comment-status"),
|
||||
("#pull-sheet .pull-sheet-panel", ".pull-comment-composer", "#pull-comment", "#send-pull-comment", "#pull-comment-status"),
|
||||
("#update-sheet .update-sheet-panel", ".update-reply", "#update-reply", "#send-update-reply", "#update-reply-status"),
|
||||
("#review-sheet .review-sheet-panel", "#review-inline-composer", "#review-inline-body", "#save-inline-comment", "#review-submit-status"),
|
||||
]:
|
||||
for selector in (panel, workspace, composer, submit, status):
|
||||
assert repr(selector) in dashboard
|
||||
@@ -27,10 +28,13 @@ def test_all_text_entry_flows_are_wired_to_keyboard_safe_panels():
|
||||
assert "workspace:qs('#create-issue-form')" in dashboard
|
||||
assert "focusWithin:true" in dashboard
|
||||
assert "mobileComposerViewport.close(qs('.create-issue-panel'))" in dashboard
|
||||
assert "mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'))" in dashboard
|
||||
assert "height:var(--composer-viewport-height,100dvh)" in css
|
||||
assert "top:var(--composer-viewport-top,0px)" in css
|
||||
assert ".composer-keyboard-active" in css
|
||||
assert ".create-issue-panel.composer-keyboard-active" in css
|
||||
assert ".review-sheet-panel.composer-keyboard-active" in css
|
||||
assert ".composer-keyboard-active .review-inline-composer" in css
|
||||
assert ".composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px;" in css
|
||||
|
||||
|
||||
@@ -41,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v93" in worker
|
||||
assert "stackchain-dashboard-shell-v98" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -186,7 +186,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v93" in worker
|
||||
assert "stackchain-dashboard-shell-v98" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -69,4 +69,4 @@ async def test_mobile_launch_progressively_discloses_secondary_controls():
|
||||
assert "header { min-height:56px; max-height:64px;" in html
|
||||
assert ".work-settings:not([open]) > .work-settings-panel { display:none; }" in html
|
||||
assert ".app-menu:not([open]) > .app-menu-panel { display:none; }" in html
|
||||
assert ".my-work-actions button { min-height:44px;" in html
|
||||
assert ".my-work-actions { display:none; }" in html
|
||||
+207
-10
@@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
|
||||
ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js"
|
||||
QUEUE_LAUNCHER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queue-launcher.js"
|
||||
TIMER = Path(__file__).resolve().parents[1] / "frontend" / "today-timer.js"
|
||||
|
||||
|
||||
@@ -116,6 +117,40 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_programmatically_selects_destination_without_running_action():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const calls = [];
|
||||
const makeButton = () => ({{
|
||||
attributes: {{}},
|
||||
setAttribute(name, value) {{ this.attributes[name] = value; }},
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }},
|
||||
}});
|
||||
const buttons = {{work:makeButton(), drafts:makeButton()}};
|
||||
const dock = createDock({{
|
||||
nav:{{}}, buttons,
|
||||
actions:{{work:()=>calls.push('work'), drafts:()=>calls.push('drafts')}},
|
||||
}});
|
||||
const selected = dock.select('drafts');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
selected, calls,
|
||||
work:buttons.work.attributes['aria-current'] || null,
|
||||
drafts:buttons.drafts.attributes['aria-current'] || null,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"selected": True,
|
||||
"calls": [],
|
||||
"work": None,
|
||||
"drafts": "page",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_keeps_today_label_separate_from_attention_count():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
@@ -240,6 +275,170 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_opens_stable_queue_switcher_and_routes_each_queue():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
class FakeElement {{
|
||||
constructor() {{ this.listeners = {{}}; this.attributes = {{}}; this.hidden = false; this.textContent = ''; this.focuses = 0; this.classList = {{contains:()=>false}}; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||
click() {{ return this.listeners.click?.({{currentTarget:this}}); }}
|
||||
setAttribute(name, value) {{ this.attributes[name] = value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
focus() {{ this.focuses += 1; }}
|
||||
}}
|
||||
const nav = new FakeElement();
|
||||
const queues = new FakeElement();
|
||||
const sheet = new FakeElement(); sheet.open = false;
|
||||
sheet.showModal = function () {{ this.open = true; }};
|
||||
sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
|
||||
const close = new FakeElement();
|
||||
const badge = new FakeElement();
|
||||
const rows = Object.fromEntries(['today','attention','update','later','draft','recaps'].map(name => [name, new FakeElement()]));
|
||||
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
|
||||
const selected = [];
|
||||
const utilities = [];
|
||||
const dock = createDock({{
|
||||
nav, buttons:{{queues}}, actions:{{queues:()=>{{}}}}, overlays:[sheet],
|
||||
queueSheet:sheet, queueClose:close, queueRows:rows, queueCounts:counts, queueBadge:badge,
|
||||
onSelectQueue:(name,row)=>name === 'recaps' ? utilities.push(row === rows.recaps ? 'recaps:trigger' : 'recaps:missing-trigger') : selected.push(name),
|
||||
observe() {{}},
|
||||
}});
|
||||
dock.start();
|
||||
dock.updateQueues({{today:2, attention:1, update:5, later:3, draft:4}});
|
||||
queues.click();
|
||||
const opened = sheet.open;
|
||||
rows.update.click();
|
||||
const closedAfterSelect = !sheet.open;
|
||||
queues.click(); rows.recaps.click();
|
||||
const closedAfterUtility = !sheet.open;
|
||||
queues.click(); close.click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
opened, closedAfterSelect, closedAfterUtility, selected, utilities,
|
||||
badge:badge.textContent, badgeLabel:queues.attributes['aria-label'],
|
||||
counts:Object.fromEntries(Object.entries(counts).map(([name, node]) => [name, node.textContent])),
|
||||
updateLabel:rows.update.attributes['aria-label'],
|
||||
queueFocuses:queues.focuses,
|
||||
columnState:nav.attributes['data-attention'] || null,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"opened": True,
|
||||
"closedAfterSelect": True,
|
||||
"closedAfterUtility": True,
|
||||
"selected": ["update"],
|
||||
"utilities": ["recaps:trigger"],
|
||||
# Updates are a drill-down within Attention and must not inflate the aggregate badge.
|
||||
"badge": "10",
|
||||
"badgeLabel": "Queues, 10 items",
|
||||
"counts": {"today": "2", "attention": "1", "update": "5", "later": "3", "draft": "4", "recaps": "0"},
|
||||
"updateLabel": "Updates, 5 unread conversations",
|
||||
"queueFocuses": 3,
|
||||
"columnState": None,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_opens_first_actionable_item_after_selecting_queue():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
const launcher = createLauncher({{
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: name => name === 'attention' ? {{click() {{ calls.push('open:first'); }}}} : null,
|
||||
announce: message => calls.push('announce:' + message),
|
||||
}});
|
||||
const opened = launcher.open('attention');
|
||||
const empty = launcher.open('later');
|
||||
process.stdout.write(JSON.stringify({{opened, empty, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"opened": "opened",
|
||||
"empty": "empty",
|
||||
"calls": [
|
||||
"filter:attention",
|
||||
"open:first",
|
||||
"filter:later",
|
||||
"announce:No deferred work is ready to open.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
let counts = {{attention: 2, today: 1, later: 4, draft: 3}};
|
||||
const launcher = createLauncher({{
|
||||
openToday: () => calls.push('today'),
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: name => (counts[name] || 0) ? {{click() {{ calls.push('open:' + name); }}}} : null,
|
||||
announce: message => calls.push('announce:' + message),
|
||||
getCounts: () => counts,
|
||||
openFindWork: () => calls.push('find'),
|
||||
}});
|
||||
const first = launcher.recommend();
|
||||
counts = {{attention: 0, today: 0, later: 4, draft: 3}};
|
||||
const opened = launcher.continueWork();
|
||||
counts = {{attention: 0, today: 0, later: 0, draft: 0}};
|
||||
const fallback = launcher.recommend();
|
||||
launcher.continueWork();
|
||||
process.stdout.write(JSON.stringify({{first, opened, fallback, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"first": {"name": "attention", "count": 2, "label": "Start Attention (2)"},
|
||||
"opened": "opened",
|
||||
"fallback": {"name": "find", "count": 0, "label": "Find Work"},
|
||||
"calls": ["filter:later", "open:later", "find"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_accessible_mobile_queue_completion_handoff():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="mobile-queue-heading"' in html
|
||||
assert 'data-mobile-queue="find"' in html
|
||||
assert "mobileQueueLauncher.recommend()" in html
|
||||
assert "showMobileQueueCompletion('Updates')" in html
|
||||
assert "row.setAttribute('data-recommended', 'true')" in html
|
||||
assert "row.focus()" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'data-mobile-task="queues"' in html
|
||||
assert 'id="mobile-queue-sheet"' in html
|
||||
assert 'aria-labelledby="mobile-queue-heading"' in html
|
||||
assert 'data-mobile-queue="today"' in html
|
||||
assert 'data-mobile-queue="attention"' in html
|
||||
assert 'data-mobile-queue="update"' in html
|
||||
assert '<strong>Updates</strong><small>Unread conversations</small>' in html
|
||||
assert 'data-mobile-queue="later"' in html
|
||||
assert 'data-mobile-queue="draft"' in html
|
||||
assert 'data-mobile-queue="recaps"' in html
|
||||
assert '<strong>Recaps</strong><small>History</small>' in html
|
||||
assert 'id="mobile-queue-count"' in html
|
||||
assert 'mobileTaskDock.updateQueues(counts)' in html
|
||||
assert '<script src="static/mobile-queue-launcher.js"></script>' in html
|
||||
assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html
|
||||
assert "firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit')" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert '.mobile-queue-sheet' in html
|
||||
assert '.my-work-actions { display:none;' in html
|
||||
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
||||
assert '.mobile-task-dock[data-attention="true"]' not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
|
||||
html = await dashboard()
|
||||
@@ -267,27 +466,25 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">' in html
|
||||
assert html.count('class="mobile-task-action" data-mobile-task=') == 6
|
||||
for task in ("work", "attention", "find", "new", "search", "drafts"):
|
||||
assert html.count('class="mobile-task-action" data-mobile-task=') == 5
|
||||
for task in ("work", "find", "new", "search", "queues"):
|
||||
assert f'data-mobile-task="{task}"' in html
|
||||
assert 'id="mobile-draft-count"' in html
|
||||
assert 'id="mobile-queue-count"' in html
|
||||
assert 'data-work-filter="attention"' in html
|
||||
assert 'id="mobile-attention-count"' in html
|
||||
assert '.mobile-task-dock { display:none;' in html
|
||||
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
|
||||
assert '.mobile-task-dock[data-attention="true"] { grid-template-columns:repeat(6,minmax(0,1fr));' in html
|
||||
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
|
||||
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
|
||||
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
||||
assert "createMobileTaskDock({" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "attention: openMobileAttention" in html
|
||||
assert "qs('[data-work-filter=\"attention\"]').click()" in html
|
||||
assert "name === 'find' ? qs('#find-work').click() : mobileQueueLauncher.open(name)" in html
|
||||
assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html
|
||||
assert "find: () => qs('#find-work').click()" in html
|
||||
assert "new: () => qs('#new-issue').click()" in html
|
||||
assert "search: () => qs('#open-palette').click()" in html
|
||||
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
|
||||
assert "draftCount.textContent = sourceDraftCount.textContent" in html
|
||||
assert "queues: () => {}" in html
|
||||
assert "mobileTaskDock.updateQueues(counts)" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
||||
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
|
||||
|
||||
@@ -324,7 +521,7 @@ async def test_mobile_attention_pauses_today_and_offers_phone_safe_return():
|
||||
|
||||
assert 'class="attention-interruption" id="attention-interruption"' in html
|
||||
assert 'id="return-to-today" type="button">Return to Today</button>' in html
|
||||
assert 'if (workSession.checkpointed()) timer.beginAttention();' in html
|
||||
assert "if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();" in html
|
||||
assert 'timer.returnFromAttention()' in html
|
||||
assert "workSession.reopen(item)" in html
|
||||
assert "attentionInterruption.hidden = !pending" in html
|
||||
|
||||
+423
-2
@@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
NOTIFICATION_UNDO = Path(__file__).parents[1] / "frontend" / "notification-undo.js"
|
||||
TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
|
||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
|
||||
@@ -24,6 +25,168 @@ PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
||||
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
|
||||
CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
|
||||
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
|
||||
WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js"
|
||||
|
||||
|
||||
def test_work_selection_selects_matching_items_in_order_with_an_explicit_cap():
|
||||
script = f"""
|
||||
const createWorkSelection = require({json.dumps(str(WORK_SELECTION))});
|
||||
const selection = createWorkSelection({{limit:3}});
|
||||
selection.start();
|
||||
selection.select({{kind:'issue', repository:'stackchain/api', number:1}});
|
||||
const result = selection.selectMany([
|
||||
{{kind:'issue', repository:'stackchain/api', number:1}},
|
||||
{{kind:'pull', repository:'stackchain/web', number:2}},
|
||||
{{kind:'issue', repository:'stackchain/app', number:3}},
|
||||
{{kind:'issue', repository:'stackchain/overflow', number:4}},
|
||||
]);
|
||||
process.stdout.write(JSON.stringify({{result, snapshot:selection.snapshot()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output == {
|
||||
"result": {"status": "limit", "added": 2, "count": 3, "limit": 3},
|
||||
"snapshot": {
|
||||
"active": True,
|
||||
"count": 3,
|
||||
"ids": [
|
||||
"issue:stackchain/api:1:",
|
||||
"pull:stackchain/web:2:",
|
||||
"issue:stackchain/app:3:",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_work_selection_clears_matches_without_leaving_selection_mode():
|
||||
script = f"""
|
||||
const createWorkSelection = require({json.dumps(str(WORK_SELECTION))});
|
||||
const selection = createWorkSelection();
|
||||
selection.start();
|
||||
selection.selectMany([
|
||||
{{kind:'issue', repository:'stackchain/api', number:1}},
|
||||
{{kind:'pull', repository:'stackchain/web', number:2}},
|
||||
]);
|
||||
process.stdout.write(JSON.stringify(selection.clear()));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"active": True, "count": 0, "ids": []}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_batch_planning_can_select_and_clear_active_queue_matches():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="select-matching-work"' in html
|
||||
assert 'id="clear-work-selection"' in html
|
||||
assert "workSelection.selectMany(visible)" in html
|
||||
assert "workSelection.clear()" in html
|
||||
assert "matches selected" in html
|
||||
assert ".selection-scope-actions button { min-height:44px;" in html
|
||||
assert "max-width:100%" in html
|
||||
|
||||
|
||||
def test_queue_finder_matches_repository_number_and_title_without_reordering():
|
||||
script = f"""
|
||||
const work = require({json.dumps(str(MY_WORK))});
|
||||
const items = [
|
||||
{{repository:'stackchain/api', key:'stackchain/api#42', number:42, title:'Retry failed deploy'}},
|
||||
{{repository:'stackchain/web', key:'stackchain/web#7', number:7, title:'Polish mobile queue'}},
|
||||
{{repository:'other/repo', key:'other/repo#42', number:42, title:'Unrelated task'}},
|
||||
];
|
||||
process.stdout.write(JSON.stringify({{
|
||||
repo:work.findQueueItems(items, 'STACKCHAIN/API').map(item => item.key),
|
||||
number:work.findQueueItems(items, '#42').map(item => item.key),
|
||||
title:work.findQueueItems(items, 'mobile queue').map(item => item.key),
|
||||
clear:work.findQueueItems(items, ' ').map(item => item.key),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"repo": ["stackchain/api#42"],
|
||||
"number": ["stackchain/api#42", "other/repo#42"],
|
||||
"title": ["stackchain/web#7"],
|
||||
"clear": ["stackchain/api#42", "stackchain/web#7", "other/repo#42"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<form class="queue-finder" id="queue-finder" role="search">' in html
|
||||
assert 'id="queue-find-input"' in html
|
||||
assert 'aria-label="Find in active work queue"' in html
|
||||
assert 'id="clear-queue-find"' in html
|
||||
assert 'id="search-older-work"' in html
|
||||
assert 'id="queue-find-status" role="status" aria-live="polite"' in html
|
||||
assert '.queue-finder input, .queue-finder button { min-height:44px;' in html
|
||||
mobile = html.index('@media (max-width: 600px)')
|
||||
assert '.queue-finder { position:sticky;' in html[mobile:]
|
||||
assert "findQueueItems(queueItems, queueFindQuery)" in html
|
||||
|
||||
|
||||
def test_today_batch_admission_is_ordered_deduplicated_and_atomic():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
function storage(fail = false) {{
|
||||
const values = new Map();
|
||||
return {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem(key, value) {{ if (fail) throw new Error('quota'); values.set(key, value); }},
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
}}
|
||||
const first = {{kind:'issue',repository:'o/r',number:1}};
|
||||
const second = {{kind:'pull',repository:'o/r',number:2}};
|
||||
const third = {{kind:'issue',repository:'o/r',number:3}};
|
||||
const today = createTodayWork({{storage:storage(),getLogin:()=> 'timmy',limit:3}});
|
||||
const admitted = today.addMany([first, first, second]);
|
||||
const existing = today.addMany([second, third]);
|
||||
const full = today.addMany([{{kind:'issue',repository:'o/r',number:4}}]);
|
||||
const broken = createTodayWork({{storage:storage(true),getLogin:()=> 'timmy'}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
admitted, existing, full, ids:today.read(), broken:broken.addMany([first, second]), brokenIds:broken.read(),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"admitted": {"status": "added", "ids": ["issue:o/r:1:", "pull:o/r:2:"]},
|
||||
"existing": {"status": "added", "ids": ["issue:o/r:3:"]},
|
||||
"full": {"status": "full", "ids": []},
|
||||
"ids": ["issue:o/r:1:", "pull:o/r:2:", "issue:o/r:3:"],
|
||||
"broken": {"status": "unavailable", "ids": []},
|
||||
"brokenIds": [],
|
||||
}
|
||||
|
||||
|
||||
def test_work_selection_tracks_cross_kind_items_and_retains_failures():
|
||||
script = f"""
|
||||
const create = require({json.dumps(str(WORK_SELECTION))});
|
||||
const selection = create({{limit:3}});
|
||||
const issue = {{kind:'issue',repository:'o/r',number:1,title:'One'}};
|
||||
const pull = {{kind:'pull',repository:'o/r',number:2,title:'Two'}};
|
||||
selection.start(); selection.select(issue); selection.select(pull);
|
||||
const before = selection.snapshot();
|
||||
selection.retain([pull]);
|
||||
process.stdout.write(JSON.stringify({{before, after:selection.snapshot(), issueId:selection.identity(issue)}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": {"active": True, "count": 2, "ids": ["issue:o/r:1:", "pull:o/r:2:"]},
|
||||
"after": {"active": True, "count": 1, "ids": ["pull:o/r:2:"]},
|
||||
"issueId": "issue:o/r:1:",
|
||||
}
|
||||
|
||||
|
||||
def test_card_planning_disclosures_keep_one_open_and_escape_restores_focus():
|
||||
@@ -395,6 +558,117 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
|
||||
assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"
|
||||
|
||||
|
||||
def test_updates_inbox_route_survives_hydration_and_detail_back_navigation():
|
||||
script = f"""
|
||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||
const listeners = {{}};
|
||||
const location = {{hash:'#/my-work/updates'}};
|
||||
const calls = [];
|
||||
const stack = ['#/my-work/updates'];
|
||||
let cursor = 0;
|
||||
const history = {{
|
||||
pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; }},
|
||||
replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; }},
|
||||
back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
|
||||
}};
|
||||
const controller = routes.createController({{
|
||||
location, history,
|
||||
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
|
||||
onQueue: queue => calls.push(['queue', queue]),
|
||||
onOpen: item => calls.push(['open', item.notification_id]),
|
||||
onClose: () => calls.push(['close']),
|
||||
onInvalid: () => calls.push(['invalid']),
|
||||
}});
|
||||
controller.start();
|
||||
controller.setItems([]);
|
||||
controller.open({{kind:'update', notification_id:42}});
|
||||
controller.close();
|
||||
process.stdout.write(JSON.stringify({{calls, hash:location.hash, parsed:routes.parse(location.hash)}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": [
|
||||
["queue", "update"],
|
||||
["open", 42],
|
||||
["close"],
|
||||
["queue", "update"],
|
||||
],
|
||||
"hash": "#/my-work/updates",
|
||||
"parsed": {"kind": "queue", "filter": "update"},
|
||||
}
|
||||
|
||||
|
||||
def test_every_mobile_queue_route_survives_hydration_and_detail_back_navigation():
|
||||
script = f"""
|
||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||
const canonical = ['today', 'attention', 'updates', 'later', 'drafts'];
|
||||
const parsed = canonical.map(name => [name, routes.parse('#/my-work/' + name)]);
|
||||
const listeners = {{}};
|
||||
const location = {{hash:'#/my-work/later'}};
|
||||
const calls = [];
|
||||
const stack = ['#/my-work/later'];
|
||||
let cursor = 0;
|
||||
const history = {{
|
||||
pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; }},
|
||||
replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; }},
|
||||
back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
|
||||
}};
|
||||
const controller = routes.createController({{
|
||||
location, history,
|
||||
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
|
||||
onQueue: queue => calls.push(['queue', queue]),
|
||||
onOpen: item => calls.push(['open', item.number]),
|
||||
onClose: () => calls.push(['close']),
|
||||
onInvalid: () => calls.push(['invalid']),
|
||||
}});
|
||||
controller.start();
|
||||
controller.setItems([{{kind:'issue', repository:'stackchain/dashboard', number:42}}]);
|
||||
controller.open({{kind:'issue', repository:'stackchain/dashboard', number:42}});
|
||||
controller.close();
|
||||
controller.queue('draft');
|
||||
process.stdout.write(JSON.stringify({{parsed, calls, hash:location.hash}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"parsed": [
|
||||
["today", {"kind": "queue", "filter": "today"}],
|
||||
["attention", {"kind": "queue", "filter": "attention"}],
|
||||
["updates", {"kind": "queue", "filter": "update"}],
|
||||
["later", {"kind": "queue", "filter": "later"}],
|
||||
["drafts", {"kind": "queue", "filter": "draft"}],
|
||||
],
|
||||
"calls": [
|
||||
["queue", "later"],
|
||||
["open", 42],
|
||||
["close"],
|
||||
["queue", "later"],
|
||||
["queue", "draft"],
|
||||
],
|
||||
"hash": "#/my-work/drafts",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_routes_reject_unknown_or_nested_fragments():
|
||||
script = f"""
|
||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||
process.stdout.write(JSON.stringify([
|
||||
routes.parse('#/my-work/tomorrow'),
|
||||
routes.parse('#/my-work/today/extra'),
|
||||
routes.parse('#/my-work/drafts/1'),
|
||||
]));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
assert json.loads(result.stdout) == [None, None, None]
|
||||
|
||||
|
||||
def test_work_route_controller_resolves_cold_routes_without_erasing_the_fragment():
|
||||
script = f"""
|
||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||
@@ -507,6 +781,10 @@ async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share
|
||||
assert html.count('class="share-work-route"') == 4
|
||||
assert 'createWorkRoute.share(window.location.href, navigator, navigator.clipboard)' in html
|
||||
assert 'Route unavailable · this item is no longer in My Work.' in html
|
||||
assert "onQueue: openWorkQueueRoute" in html
|
||||
assert "selectWorkQueue(filter, { preserveRoute:true })" in html
|
||||
assert "workRoute.queue(filter)" in html
|
||||
assert "openDeliveryReceiptRoute" not in html
|
||||
|
||||
|
||||
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
|
||||
@@ -1227,6 +1505,59 @@ process.stdout.write(JSON.stringify({{noIdentity, invalid, failedWrite}}));
|
||||
}
|
||||
|
||||
|
||||
def test_later_queue_defers_a_batch_atomically_with_one_wake_time_and_change_per_item():
|
||||
script = f"""
|
||||
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
||||
const values = new Map();
|
||||
const changes = [];
|
||||
let writes = 0;
|
||||
let fail = false;
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => {{ writes += 1; if (fail) throw new Error('quota'); values.set(key,value); }},
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const first = {{kind:'update',repository:'stackchain/api',number:17,notification_id:91}};
|
||||
const second = {{kind:'update',repository:'stackchain/web',number:8,notification_id:92}};
|
||||
const store = createLaterWork({{
|
||||
storage, getLogin:() => 'timmy', now:() => new Date('2026-08-08T12:00:00Z'),
|
||||
setTimer:() => 1, clearTimer:() => {{}},
|
||||
onChange:(...change) => changes.push(change),
|
||||
}});
|
||||
const deferred = store.deferMany([first, second], new Date('2026-08-09T09:00:00Z'));
|
||||
const saved = store.partition([first, second]);
|
||||
const beforeFailure = JSON.stringify(store.read());
|
||||
fail = true;
|
||||
const unavailable = store.deferMany([
|
||||
{{kind:'update',repository:'stackchain/api',number:19,notification_id:93}},
|
||||
{{kind:'update',repository:'stackchain/api',number:20,notification_id:94}},
|
||||
], new Date('2026-08-10T09:00:00Z'));
|
||||
process.stdout.write(JSON.stringify({{
|
||||
deferred, unavailable, writes, changes,
|
||||
later:saved.later.map(item => [item.notification_id,item.deferred_until]),
|
||||
unchanged:beforeFailure === JSON.stringify(store.read()),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"deferred": "deferred",
|
||||
"unavailable": "unavailable",
|
||||
"writes": 2,
|
||||
"changes": [
|
||||
["defer", "update:stackchain/api:17:91", "2026-08-09T09:00:00.000Z"],
|
||||
["defer", "update:stackchain/web:8:92", "2026-08-09T09:00:00.000Z"],
|
||||
],
|
||||
"later": [
|
||||
[91, "2026-08-09T09:00:00.000Z"],
|
||||
[92, "2026-08-09T09:00:00.000Z"],
|
||||
],
|
||||
"unchanged": True,
|
||||
}
|
||||
|
||||
|
||||
def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload():
|
||||
script = f"""
|
||||
const createLaterWork = require({json.dumps(str(LATER_WORK))});
|
||||
@@ -3014,7 +3345,10 @@ controller.load().then(() => controller.loadMore()).then(() =>
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == ["api/v1/available-issues?page=1", "api/v1/available-issues?page=2"]
|
||||
assert output["calls"] == [
|
||||
"api/v1/available-issues?page=1&facets=true",
|
||||
"api/v1/available-issues?page=2&facets=true",
|
||||
]
|
||||
assert [item["id"] for item in output["items"]] == [1, 2]
|
||||
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
|
||||
|
||||
@@ -4076,6 +4410,58 @@ const reader = buildMyWork.createNotificationReader({{
|
||||
assert output["results"][1] is False
|
||||
|
||||
|
||||
def test_notification_undo_restores_exact_item_once_and_keeps_failure_retryable():
|
||||
script = f"""
|
||||
const createNotificationUndo = require({json.dumps(str(NOTIFICATION_UNDO))});
|
||||
const states = [];
|
||||
const statuses = [];
|
||||
const calls = [];
|
||||
let attempts = 0;
|
||||
const item = {{kind:'update', key:'repo#7', notification_id:42, has_update:true}};
|
||||
const undo = createNotificationUndo({{
|
||||
restore: async id => {{ calls.push(id); attempts += 1; if (attempts === 1) throw new Error('offline'); }},
|
||||
onItems: items => states.push(items.map(value => value.notification_id)),
|
||||
onStatus: status => statuses.push(status),
|
||||
}});
|
||||
(async () => {{
|
||||
undo.offer(item, [{{kind:'update', notification_id:43, has_update:true}}]);
|
||||
const first = undo.run();
|
||||
const duplicate = undo.run();
|
||||
const firstResults = await Promise.all([first, duplicate]);
|
||||
const retry = await undo.run();
|
||||
process.stdout.write(JSON.stringify({{calls, states, statuses, firstResults, retry}}));
|
||||
}})();
|
||||
"""
|
||||
output = json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
assert output["calls"] == [42, 42]
|
||||
assert output["states"] == [[42, 43]]
|
||||
assert output["firstResults"] == [False, False]
|
||||
assert output["retry"] is True
|
||||
assert output["statuses"] == [
|
||||
"repo#7 marked read. Undo?",
|
||||
"Restoring repo#7…",
|
||||
"Could not restore repo#7. Retry Undo.",
|
||||
"Restoring repo#7…",
|
||||
"repo#7 is unread again.",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_undo_is_accessible_and_safe_area_aware():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="notification-undo"' in html
|
||||
assert 'id="undo-notification" type="button"' in html
|
||||
assert "requestNotificationUnread" in html
|
||||
assert "createDashboardNotificationUndo({" in html
|
||||
assert ".notification-undo { position:fixed;" in html
|
||||
assert "env(safe-area-inset-bottom)" in html
|
||||
assert ".notification-undo button { min-height:44px;" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next():
|
||||
html = await dashboard()
|
||||
@@ -4354,7 +4740,9 @@ reader.open(item, [item]).then(() => {{
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["result"] == {"items": [], "next": None}
|
||||
assert output["result"] == {"items": [], "item": {
|
||||
"kind": "update", "notification_id": 42, "has_update": True,
|
||||
}, "next": None}
|
||||
assert output["events"] == [
|
||||
["status", "Marking update read…"],
|
||||
["items", []],
|
||||
@@ -5412,6 +5800,39 @@ async def test_updates_view_offers_accessible_sticky_mobile_subset_selection():
|
||||
assert "body: JSON.stringify({ ids })" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_selected_updates_can_be_deferred_together_without_marking_them_read():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="defer-selected-today"' in html
|
||||
assert 'id="defer-selected-tomorrow"' in html
|
||||
assert 'laterWork.deferMany(selectedUpdates, until)' in html
|
||||
assert "notificationSelection.cancel()" in html
|
||||
assert "Selection unchanged." in html
|
||||
assert "work stays unread and unchanged in Gitea" in html
|
||||
assert '.my-work-bulk-actions { display:grid;' in html
|
||||
assert '.my-work-bulk-actions button { min-height:44px; width:100%; }' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_work_queues_batch_plan_cross_kind_selection():
|
||||
html = await dashboard()
|
||||
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert '<script src="static/work-selection.js"></script>' in html
|
||||
assert 'id="select-work"' in html
|
||||
assert 'id="batch-add-today"' in html
|
||||
assert 'createWorkSelection({ limit: 50' in html
|
||||
assert 'todayWork.addMany(selectedItems)' in html
|
||||
assert 'laterWork.deferMany(selectedItems, until)' in html
|
||||
assert 'data-select-work-id=' in html
|
||||
assert 'Select work ' in html
|
||||
assert "workSelectionState.count + ' selected'" in html
|
||||
assert '.work-selector { min-height:44px;' in html
|
||||
assert '.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }' in html
|
||||
assert "BASE + 'static/work-selection.js'" in service_worker
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -41,6 +41,29 @@ async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monk
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mark_notification_unread_calls_supported_gitea_thread_endpoint(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class Response:
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
class Client:
|
||||
async def patch(self, path, headers):
|
||||
calls.append((path, headers))
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
|
||||
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
|
||||
|
||||
await gitea_proxy.mark_notification_unread(42)
|
||||
|
||||
assert calls == [
|
||||
("/api/v1/notifications/threads/42?to-status=unread", {"Authorization": "token test"})
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acknowledge_notification_resolves_latest_comment_and_adds_one_reaction(monkeypatch):
|
||||
calls = []
|
||||
@@ -172,6 +195,26 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
|
||||
assert marked == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monkeypatch):
|
||||
restored = []
|
||||
|
||||
async def mark_unread(thread_id):
|
||||
restored.append(thread_id)
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mark_notification_unread", mark_unread, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.patch("/api/v1/notifications/42/unread")
|
||||
invalid = await client.patch("/api/v1/notifications/0/unread")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"id": 42, "status": "unread"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert invalid.status_code == 422
|
||||
assert restored == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
|
||||
calls = []
|
||||
|
||||
@@ -104,3 +104,44 @@ def test_existing_challenge_registry_is_migrated_without_losing_live_challenges(
|
||||
).fetchall()
|
||||
assert "source_hash" in columns
|
||||
assert sorted(rows) == sorted([("existing",), (store._digest(b"new"),)])
|
||||
|
||||
|
||||
def test_passkey_counter_advancement_is_atomic_across_store_instances(tmp_path):
|
||||
database = tmp_path / "passkeys.sqlite3"
|
||||
first = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
second = PasskeyStore(database, clock=lambda: 1_000.0)
|
||||
first.register(
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
|
||||
assert first.advance_counter(b"phone-credential", expected=4, new=5) is True
|
||||
assert second.advance_counter(b"phone-credential", expected=4, new=5) is False
|
||||
assert first.get(b"phone-credential").sign_count == 5
|
||||
|
||||
|
||||
def test_passkey_counter_rejects_non_advancing_values_but_supports_counterless_devices(
|
||||
tmp_path,
|
||||
):
|
||||
store = PasskeyStore(tmp_path / "passkeys.sqlite3", clock=lambda: 1_000.0)
|
||||
store.register(
|
||||
credential_id=b"phone-credential",
|
||||
public_key=b"phone-public-key",
|
||||
sign_count=4,
|
||||
device_label="Phone",
|
||||
management_id="phone-management-id",
|
||||
)
|
||||
store.register(
|
||||
credential_id=b"counterless-credential",
|
||||
public_key=b"counterless-public-key",
|
||||
sign_count=0,
|
||||
device_label="Security key",
|
||||
management_id="counterless-management-id",
|
||||
)
|
||||
|
||||
assert store.advance_counter(b"phone-credential", expected=4, new=4) is False
|
||||
assert store.advance_counter(b"phone-credential", expected=4, new=3) is False
|
||||
assert store.advance_counter(b"counterless-credential", expected=0, new=0) is True
|
||||
@@ -164,6 +164,77 @@ process.stdout.write(JSON.stringify({{before,ignored,applied,after,result,saved}
|
||||
}
|
||||
|
||||
|
||||
def test_plan_today_builds_ranked_ready_capacity_fit_without_saving():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
const item = (number, reason) => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number, reason}});
|
||||
const saved = [];
|
||||
+const planner = createPlanToday({{
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
save: plan => saved.push(plan),
|
||||
}});
|
||||
const id = n => 'issue:stackchain/dashboard:' + n + ':';
|
||||
planner.open([], [item(1, 'Due today'), item(2, 'Unread update'), item(3, 'Assigned to you'), item(4, 'Needs your review')], {{
|
||||
capacity_minutes:90, estimates:{{[id(1)]:60,[id(2)]:45,[id(3)]:30,[id(4)]:20}}
|
||||
}});
|
||||
const build = planner.buildRecommendation({{
|
||||
[id(1)]:{{status:'ready'}},
|
||||
[id(2)]:{{status:'blocked', reason:'Waiting on API'}},
|
||||
[id(3)]:{{status:'ready'}},
|
||||
[id(4)]:{{status:'unverified'}},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{build, snapshot:planner.snapshot(), saved}}));
|
||||
""".replace("+const", "const")
|
||||
result = run_node(script)
|
||||
assert result["build"] == {
|
||||
"selected": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:3:"],
|
||||
"reasons": {
|
||||
"issue:stackchain/dashboard:1:": "Due today",
|
||||
"issue:stackchain/dashboard:3:": "Assigned to you",
|
||||
},
|
||||
"skipped": [
|
||||
{"id": "issue:stackchain/dashboard:2:", "status": "blocked", "reason": "Waiting on API"},
|
||||
{"id": "issue:stackchain/dashboard:4:", "status": "unverified", "reason": "Could not verify readiness"},
|
||||
],
|
||||
"needs_estimate": [],
|
||||
}
|
||||
assert result["snapshot"]["ids"] == [
|
||||
"issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:3:"
|
||||
]
|
||||
assert result["snapshot"]["planned_minutes"] == 90
|
||||
assert result["snapshot"]["remaining_minutes"] == 0
|
||||
assert result["snapshot"]["build"] == result["build"]
|
||||
assert result["saved"] == []
|
||||
|
||||
|
||||
def test_plan_today_estimates_ready_omission_and_refits_without_another_readiness_check():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const planner = createPlanToday({{
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
save: () => true,
|
||||
}});
|
||||
const id = n => 'issue:stackchain/dashboard:' + n + ':';
|
||||
planner.open([], [item(1), item(2)], {{capacity_minutes:60, estimates:{{[id(1)]:30}}}});
|
||||
planner.buildRecommendation({{[id(1)]:{{status:'ready'}}, [id(2)]:{{status:'ready'}}}});
|
||||
const before = planner.snapshot();
|
||||
const estimated = planner.setEstimate(id(2), 30);
|
||||
const after = planner.snapshot();
|
||||
process.stdout.write(JSON.stringify({{before, estimated, after}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["before"]["ids"] == ["issue:stackchain/dashboard:1:"]
|
||||
assert result["before"]["build"]["needs_estimate"] == ["issue:stackchain/dashboard:2:"]
|
||||
assert result["estimated"] is True
|
||||
assert result["after"]["ids"] == [
|
||||
"issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"
|
||||
]
|
||||
assert result["after"]["estimates"]["issue:stackchain/dashboard:2:"] == 30
|
||||
assert result["after"]["remaining_minutes"] == 0
|
||||
assert result["after"]["build"]["needs_estimate"] == []
|
||||
|
||||
|
||||
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
@@ -278,6 +349,12 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
|
||||
assert 'id="plan-today-sheet" role="dialog"' in html
|
||||
assert 'id="plan-today-capacity"' in html
|
||||
assert 'id="plan-today-available"' in html
|
||||
assert 'id="build-today-plan"' in html
|
||||
assert 'id="plan-today-build-status" role="status" aria-live="polite"' in html
|
||||
assert 'id="plan-today-estimates"' in html
|
||||
assert 'Estimate to finish your plan' in html
|
||||
assert 'data-plan-missing-estimate="' in html
|
||||
assert '.plan-today-estimates input { min-height:44px;' in html
|
||||
assert 'inputmode="numeric" min="15" max="1440"' in html
|
||||
assert 'data-plan-estimate="' in html
|
||||
assert 'aria-label="Estimate for ' in html
|
||||
@@ -333,6 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -0,0 +1,94 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
READINESS = Path(__file__).parents[1] / "frontend" / "plan-today-readiness.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_plan_readiness_bounds_concurrency_and_preserves_ranked_order():
|
||||
script = f"""
|
||||
const createReadiness = require({json.dumps(str(READINESS))});
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const completed = [];
|
||||
const delays = {{1:40, 2:5, 3:20, 4:1, 5:10, 6:2}};
|
||||
const readiness = createReadiness({{
|
||||
concurrency:3,
|
||||
identity:item => 'issue:' + item.number,
|
||||
inspect:async item => {{
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise(resolve => setTimeout(resolve, delays[item.number]));
|
||||
completed.push(item.number);
|
||||
active -= 1;
|
||||
return item.number === 4 ? {{status:'blocked', reason:'Waiting'}} : {{status:'ready'}};
|
||||
}},
|
||||
}});
|
||||
readiness.run([1,2,3,4,5,6].map(number => ({{kind:'issue', number}}))).then(result => {{
|
||||
process.stdout.write(JSON.stringify({{peak, completed, entries:Object.entries(result)}}));
|
||||
}});
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["peak"] == 3
|
||||
assert result["completed"] != [1, 2, 3, 4, 5, 6]
|
||||
assert result["entries"] == [
|
||||
["issue:1", {"status": "ready"}],
|
||||
["issue:2", {"status": "ready"}],
|
||||
["issue:3", {"status": "ready"}],
|
||||
["issue:4", {"status": "blocked", "reason": "Waiting"}],
|
||||
["issue:5", {"status": "ready"}],
|
||||
["issue:6", {"status": "ready"}],
|
||||
]
|
||||
|
||||
|
||||
def test_plan_readiness_supersedes_stale_builds_and_normalizes_real_failures():
|
||||
script = f"""
|
||||
const createReadiness = require({json.dumps(str(READINESS))});
|
||||
let releaseFirst;
|
||||
const firstGate = new Promise(resolve => {{ releaseFirst = resolve; }});
|
||||
const readiness = createReadiness({{
|
||||
concurrency:2,
|
||||
identity:item => 'issue:' + item.number,
|
||||
inspect:async item => {{
|
||||
if (item.number === 1) await firstGate;
|
||||
if (item.number === 3) throw new Error('upstream details');
|
||||
return {{status:'ready'}};
|
||||
}},
|
||||
}});
|
||||
const stale = readiness.run([{{number:1}}, {{number:2}}]);
|
||||
const current = readiness.run([{{number:3}}, {{number:4}}]);
|
||||
current.then(currentResult => {{
|
||||
releaseFirst();
|
||||
stale.then(staleResult => process.stdout.write(JSON.stringify({{staleResult, currentResult}})));
|
||||
}});
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"staleResult": None,
|
||||
"currentResult": {
|
||||
"issue:3": {"status": "unverified"},
|
||||
"issue:4": {"status": "ready"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_uses_bounded_supersedable_readiness_for_build_my_today():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/plan-today-readiness.js"></script>' in html
|
||||
assert "const planTodayReadiness = createPlanTodayReadiness({" in html
|
||||
assert "concurrency:3" in html
|
||||
assert "const checks = await planTodayReadiness.run(candidates);" in html
|
||||
assert "if (!checks) return;" in html
|
||||
assert "planTodayReadiness.cancel();" in html
|
||||
assert "Promise.all(candidates.map" not in html
|
||||
@@ -0,0 +1,37 @@
|
||||
import pytest
|
||||
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
["127.0.0.1", "::1", "10.0.0.4", "169.254.169.254", "fc00::1"],
|
||||
)
|
||||
async def test_push_endpoint_rejects_every_non_public_resolved_address(address):
|
||||
async def resolve(_host, _port):
|
||||
return [address]
|
||||
|
||||
with pytest.raises(UnsafePushEndpoint, match="public Web Push service"):
|
||||
await validate_public_push_endpoint(
|
||||
"https://push.example/device", resolver=resolve
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint",
|
||||
[
|
||||
"http://push.example/device",
|
||||
"https://user@push.example/device",
|
||||
"https://push.example:8443/device",
|
||||
"https://push.example:not-a-port/device",
|
||||
"https://push.example/device#fragment",
|
||||
],
|
||||
)
|
||||
async def test_push_endpoint_rejects_noncanonical_authorities_without_resolving(endpoint):
|
||||
async def must_not_resolve(_host, _port):
|
||||
raise AssertionError("invalid URL reached DNS")
|
||||
|
||||
with pytest.raises(UnsafePushEndpoint, match="canonical public Web Push"):
|
||||
await validate_public_push_endpoint(endpoint, resolver=must_not_resolve)
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import asyncio
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
@@ -10,6 +11,15 @@ import pytest
|
||||
from src import dashboard_auth, gitea_proxy, main
|
||||
from src.push_notifications import PushConfiguration, dispatch_unread_updates
|
||||
from src.push_subscription_store import PushSubscriptionStore
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def resolve_test_push_endpoints_publicly(monkeypatch):
|
||||
async def accept(endpoint):
|
||||
return endpoint
|
||||
|
||||
monkeypatch.setattr(main, "validate_public_push_endpoint", accept)
|
||||
|
||||
|
||||
def test_dispatch_lease_is_exclusive_recoverable_and_owner_fenced(tmp_path):
|
||||
@@ -508,6 +518,32 @@ async def test_dispatch_removes_an_expired_push_endpoint(tmp_path):
|
||||
assert store.is_subscribed("session-a") is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatch_removes_an_endpoint_that_rebinds_private_without_contacting_it(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("unsafe-device", {
|
||||
"endpoint": "https://push.example/rebound",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
contacted = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"id": 8}]}
|
||||
|
||||
async def reject_rebound(_endpoint):
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
|
||||
|
||||
async def send(subscription, _payload):
|
||||
contacted.append(subscription["endpoint"])
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch_unread_updates(
|
||||
store, config, unread, send, endpoint_validator=reject_rebound
|
||||
) == 0
|
||||
assert contacted == []
|
||||
assert store.is_subscribed("unsafe-device") is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
@@ -532,6 +568,75 @@ async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_pat
|
||||
assert store.claim_unseen({8})[0].session_id == "session-a"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unexpected_device_failure_waits_for_siblings_and_retries_only_that_device(tmp_path):
|
||||
path = tmp_path / "push.sqlite3"
|
||||
store = PushSubscriptionStore(path)
|
||||
competitor = PushSubscriptionStore(path)
|
||||
for session_id in ("failing-device", "healthy-device"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
healthy_started = asyncio.Event()
|
||||
release_healthy = asyncio.Event()
|
||||
sent = []
|
||||
|
||||
async def unread():
|
||||
return {"items": [{"id": 8, "updated_at": "r1"}], "complete": True}
|
||||
|
||||
async def validate(endpoint):
|
||||
if endpoint.endswith("failing-device"):
|
||||
raise TimeoutError("endpoint validation unavailable")
|
||||
return endpoint
|
||||
|
||||
async def send(subscription, _payload):
|
||||
sent.append(subscription["endpoint"])
|
||||
healthy_started.set()
|
||||
await release_healthy.wait()
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
dispatch = asyncio.create_task(dispatch_unread_updates(
|
||||
store,
|
||||
config,
|
||||
unread,
|
||||
send,
|
||||
endpoint_validator=validate,
|
||||
))
|
||||
await asyncio.wait_for(healthy_started.wait(), timeout=0.2)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert dispatch.done() is False
|
||||
assert competitor.acquire_dispatch_lease(
|
||||
"competing-worker", now=time.time(), lease_seconds=60
|
||||
) is False
|
||||
|
||||
release_healthy.set()
|
||||
assert await dispatch == 1
|
||||
remaining = store.claim_unseen({8: "r1"})
|
||||
assert [(item.session_id, item.thread_ids) for item in remaining] == [
|
||||
("failing-device", (8,))
|
||||
]
|
||||
|
||||
retried = []
|
||||
|
||||
async def validate_recovered(endpoint):
|
||||
return endpoint
|
||||
|
||||
async def send_retry(subscription, _payload):
|
||||
retried.append(subscription["endpoint"])
|
||||
|
||||
assert await dispatch_unread_updates(
|
||||
store,
|
||||
config,
|
||||
unread,
|
||||
send_retry,
|
||||
endpoint_validator=validate_recovered,
|
||||
) == 1
|
||||
assert retried == ["https://push.example/failing-device"]
|
||||
assert sent == ["https://push.example/healthy-device"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_transient_failure_stops_that_device_until_the_next_poll(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
@@ -722,6 +827,37 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||
assert (await main.push_status(request))["subscribed"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
monkeypatch.setattr(
|
||||
main, "_push_configuration",
|
||||
lambda: PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
)
|
||||
|
||||
async def management_id(_session):
|
||||
return "unsafe-device"
|
||||
|
||||
async def reject_private(_endpoint):
|
||||
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
monkeypatch.setattr(main, "validate_public_push_endpoint", reject_private)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
payload = main.PushSubscriptionPayload(
|
||||
endpoint="https://push.example/device-a",
|
||||
keys={"p256dh": "public-key", "auth": "auth-secret"},
|
||||
)
|
||||
|
||||
with pytest.raises(main.HTTPException) as rejected:
|
||||
await main.subscribe_push(payload, request)
|
||||
|
||||
assert rejected.value.status_code == 422
|
||||
assert rejected.value.detail == "Endpoint must resolve to a public Web Push service"
|
||||
assert store.is_subscribed("unsafe-device") is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_subscription_baselines_every_revision_from_the_complete_snapshot(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
|
||||
@@ -47,3 +47,42 @@ const boundary={{
|
||||
"hidden": False,
|
||||
"focused": True,
|
||||
}
|
||||
|
||||
|
||||
def test_counter_anomaly_activity_gives_passkey_remediation_guidance():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const element=()=>({{
|
||||
hidden:true, disabled:false, textContent:'', children:[], className:'',
|
||||
addEventListener(){{}}, focus(){{}}, replaceChildren(){{this.children=[];}},
|
||||
append(...values){{this.children.push(...values);}},
|
||||
}});
|
||||
const ids={{
|
||||
'active-devices':element(), 'active-devices-sheet':element(),
|
||||
'active-devices-list':element(), 'active-devices-status':element(),
|
||||
'enrolled-passkeys-list':element(), 'enrolled-passkeys-status':element(),
|
||||
'enroll-passkey':element(), 'security-activity-list':element(),
|
||||
'security-activity-status':element(), 'load-more-security-activity':element(),
|
||||
'close-active-devices':element(),
|
||||
}};
|
||||
const root={{document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}}}};
|
||||
const boundary={{
|
||||
listActiveDevices:async()=>[], listPasskeys:async()=>[],
|
||||
listSecurityEvents:async()=>({{events:[{{
|
||||
kind:'passkey_counter_anomaly', device_label:'Phone', method:'passkey',
|
||||
target:'sign_in:dashboard', status:'completed', created_at:1,
|
||||
}}],authentication_alerts:[],next_cursor:null}}),
|
||||
}};
|
||||
(async()=>{{
|
||||
await attachSecurityCenter({{root,boundary}}).open();
|
||||
const row=ids['security-activity-list'].children[0];
|
||||
console.log(JSON.stringify(row.children.map(child=>child.textContent)));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
title, detail = json.loads(completed.stdout)
|
||||
assert title == "Passkey counter anomaly"
|
||||
assert "Remove and re-enroll this passkey" in detail
|
||||
@@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const storedResponses = new Map();
|
||||
storedResponses.set(
|
||||
'https://forge.example/dashboard/__offline-session-lease',
|
||||
@@ -24,6 +24,10 @@ storedResponses.set(
|
||||
);
|
||||
const cache = {{
|
||||
addAll: async urls => {{ state.added = urls; }},
|
||||
add: async url => {{
|
||||
if (state.failedAdds.includes(url)) throw new Error('optional asset unavailable');
|
||||
state.individuallyAdded.push(url);
|
||||
}},
|
||||
match: async request => {{
|
||||
const key = String(request.url || request);
|
||||
if (storedResponses.has(key)) return storedResponses.get(key).clone();
|
||||
@@ -89,7 +93,7 @@ const context = {{
|
||||
}},
|
||||
}};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf;', context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf; self.__testOptionalFeatures = OPTIONAL_FEATURES;', context);
|
||||
async function dispatch(name, request) {{
|
||||
let pending;
|
||||
let response;
|
||||
@@ -141,7 +145,7 @@ async function dispatchPush(payload) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -150,14 +154,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -165,7 +169,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -173,14 +177,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -189,21 +193,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -650,7 +654,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -680,6 +684,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
"/dashboard/static/unfiled-captures.js",
|
||||
"/dashboard/static/draft-filing-session.js",
|
||||
"/dashboard/static/draft-capacity-dialog.js",
|
||||
"/dashboard/static/outbox-coordinator.js",
|
||||
"/dashboard/static/issue-outbox.js",
|
||||
"/dashboard/static/authored-outbox.js",
|
||||
@@ -689,7 +695,9 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/offline-work.js",
|
||||
"/dashboard/static/offline-today.js",
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/notification-undo.js",
|
||||
"/dashboard/static/card-planning.js",
|
||||
"/dashboard/static/work-selection.js",
|
||||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/today-timer.js",
|
||||
"/dashboard/static/today-recap.js",
|
||||
@@ -698,16 +706,19 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/comment-next.js",
|
||||
"/dashboard/static/update-reply-read-next.js",
|
||||
"/dashboard/static/plan-today.js",
|
||||
"/dashboard/static/plan-today-readiness.js",
|
||||
"/dashboard/static/plan-today-preview.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/today-rollover.js",
|
||||
"/dashboard/static/update-ownership.js",
|
||||
"/dashboard/static/update-follow-up.js",
|
||||
"/dashboard/static/later-work.js",
|
||||
"/dashboard/static/later-sync.js",
|
||||
"/dashboard/static/later-and-start.js",
|
||||
"/dashboard/static/detail-defer.js",
|
||||
"/dashboard/static/later-picker.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
"/dashboard/static/batch-find-work.js",
|
||||
"/dashboard/static/conversation.js",
|
||||
"/dashboard/static/comment-actions.js",
|
||||
"/dashboard/static/issue-attachment.js",
|
||||
@@ -723,6 +734,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/context-poller.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-queue-launcher.js",
|
||||
"/dashboard/static/mobile-launch.js",
|
||||
"/dashboard/static/install-app.js",
|
||||
"/dashboard/static/mobile-device-setup.js",
|
||||
@@ -746,6 +758,45 @@ def test_activate_deletes_only_stale_stackchain_caches():
|
||||
assert result["deleted"] == ["stackchain-dashboard-old"]
|
||||
|
||||
|
||||
def test_activate_warms_optional_features_without_blocking_siblings_or_claim():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.failedAdds = ['/dashboard/feature-security-center-test.js'];
|
||||
context.self.__testOptionalFeatures.push(
|
||||
'/dashboard/feature-issue-capture-test.js',
|
||||
'/dashboard/feature-security-center-test.js',
|
||||
'/dashboard/feature-pull-workflow-test.js',
|
||||
);
|
||||
await dispatch('activate');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["claimed"] is True
|
||||
assert result["individuallyAdded"] == [
|
||||
"/dashboard/feature-issue-capture-test.js",
|
||||
"/dashboard/feature-pull-workflow-test.js",
|
||||
]
|
||||
|
||||
|
||||
def test_warmed_optional_feature_is_served_from_cache_while_offline():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
context.self.__testOptionalFeatures.push('/dashboard/feature-issue-capture-test.js');
|
||||
state.cachedBody = 'cached feature';
|
||||
state.failFetch = true;
|
||||
const response = await dispatch('fetch', {
|
||||
method: 'GET', mode: 'cors',
|
||||
url: 'https://forge.example/dashboard/feature-issue-capture-test.js',
|
||||
});
|
||||
process.stdout.write(JSON.stringify({ body: await response.text(), state }));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["body"] == "cached feature"
|
||||
assert result["state"]["fetches"] == []
|
||||
|
||||
|
||||
def test_offline_navigation_returns_cached_shell_for_share_target_url():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v93';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v98';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "stackchain-dashboard-shell-v98" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
UNFILED = Path(__file__).parents[1] / "frontend" / "unfiled-captures.js"
|
||||
DRAFT_SESSION = Path(__file__).parents[1] / "frontend" / "draft-filing-session.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
@@ -15,7 +16,29 @@ def run_node(script: str):
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_unfiled_captures_keep_twenty_newest_account_bound_notes():
|
||||
def test_draft_filing_session_navigates_stable_capture_ids_and_reconciles_removed_items():
|
||||
script = f"""
|
||||
const createDraftFilingSession = require({json.dumps(str(DRAFT_SESSION))});
|
||||
let captures = [{{id:'newest'}}, {{id:'middle'}}, {{id:'oldest'}}];
|
||||
const session = createDraftFilingSession({{list:()=>captures}});
|
||||
const opened = session.start('middle');
|
||||
const skipped = session.next();
|
||||
captures = [{{id:'newest'}}];
|
||||
const afterFiled = session.removeCurrentAndNext('oldest');
|
||||
const previous = session.previous();
|
||||
process.stdout.write(JSON.stringify({{opened,skipped,afterFiled,previous}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"opened": {"id": "middle", "position": 2, "total": 3, "remaining": 1},
|
||||
"skipped": {"id": "oldest", "position": 3, "total": 3, "remaining": 0},
|
||||
"afterFiled": {"id": "newest", "position": 1, "total": 1, "remaining": 0},
|
||||
"previous": {"id": "newest", "position": 1, "total": 1, "remaining": 0},
|
||||
}
|
||||
|
||||
|
||||
def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
@@ -32,25 +55,203 @@ const captures = createUnfiledCaptures({{
|
||||
createId:()=>String(++sequence),
|
||||
now:()=>1000 + sequence,
|
||||
}});
|
||||
for (let index = 1; index <= 22; index += 1) {{
|
||||
for (let index = 1; index <= 20; index += 1) {{
|
||||
captures.save({{title:'Note ' + index, body:'Context ' + index}});
|
||||
}}
|
||||
let fullError = '';
|
||||
try {{ captures.save({{title:'Note 21', body:'Context 21'}}); }} catch (error) {{
|
||||
fullError = error.message;
|
||||
}}
|
||||
const beforeReplace = captures.list();
|
||||
const oldest = captures.capacity().oldest;
|
||||
const replacement = captures.replaceOldest({{title:'Note 21', body:'Context 21'}}, oldest.id);
|
||||
const offline = captures.list();
|
||||
const restored = createUnfiledCaptures({{
|
||||
storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy'
|
||||
}}).list();
|
||||
process.stdout.write(JSON.stringify({{offline, restored}}));
|
||||
process.stdout.write(JSON.stringify({{fullError,beforeReplace,oldest,replacement,offline,restored}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["fullError"] == "Drafts full — nothing was deleted."
|
||||
assert len(output["beforeReplace"]) == 20
|
||||
assert output["beforeReplace"][-1]["title"] == "Note 1"
|
||||
assert output["oldest"]["title"] == "Note 1"
|
||||
assert len(output["offline"]) == 20
|
||||
assert output["offline"][0]["title"] == "Note 22"
|
||||
assert output["offline"][-1]["title"] == "Note 3"
|
||||
assert output["offline"][0]["title"] == "Note 21"
|
||||
assert output["offline"][-1]["title"] == "Note 2"
|
||||
assert all(item["quarantined"] for item in output["offline"])
|
||||
assert all(not item["quarantined"] for item in output["restored"])
|
||||
assert output["restored"][0]["ownerLogin"] == "timmy"
|
||||
|
||||
|
||||
def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const blobs = new Map();
|
||||
const deleted = [];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const attachmentStore = {{
|
||||
put: async (id, attachment) => blobs.set(id, attachment),
|
||||
get: async id => blobs.get(id) || null,
|
||||
delete: async id => {{ deleted.push(id); blobs.delete(id); }},
|
||||
}};
|
||||
(async () => {{
|
||||
let id = 0;
|
||||
const captures = createUnfiledCaptures({{
|
||||
storage, attachmentStore, maxItems:2, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
|
||||
createId:()=>String(++id), now:()=>id,
|
||||
}});
|
||||
const image = name => ({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
|
||||
const first = await captures.save({{title:'First',body:'one',attachment:image('first.png')}});
|
||||
const second = await captures.save({{title:'Second',body:'two',attachment:image('second.png')}});
|
||||
let mismatch = '';
|
||||
try {{ await captures.replaceOldest({{title:'Third',body:'three'}}, second.id); }}
|
||||
catch (error) {{ mismatch = error.message; }}
|
||||
const third = await captures.replaceOldest({{title:'Third',body:'three',attachment:image('third.png')}}, first.id);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
mismatch, titles:captures.list().map(item=>item.title), deleted,
|
||||
blobs:[...blobs.keys()], third:third.title,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"mismatch": "Drafts changed. Review them before replacing anything.",
|
||||
"titles": ["Third", "Second"],
|
||||
"deleted": ["1"],
|
||||
"blobs": ["2", "3"],
|
||||
"third": "Third",
|
||||
}
|
||||
|
||||
|
||||
def test_failed_replacement_attachment_cleanup_restores_original_draft_and_removes_staged_blob():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const blobs = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const attachmentStore = {{
|
||||
put: async (id, attachment) => blobs.set(id, attachment),
|
||||
get: async id => blobs.get(id) || null,
|
||||
delete: async id => {{
|
||||
if (id === '1') throw new Error('IndexedDB delete failed');
|
||||
blobs.delete(id);
|
||||
}},
|
||||
}};
|
||||
(async () => {{
|
||||
let id = 0;
|
||||
const captures = createUnfiledCaptures({{
|
||||
storage, attachmentStore, maxItems:1, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
|
||||
createId:()=>String(++id), now:()=>id,
|
||||
}});
|
||||
const image = name => ({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
|
||||
const original = await captures.save({{title:'Original',body:'keep me',attachment:image('original.png')}});
|
||||
let error = '';
|
||||
try {{ await captures.replaceOldest({{title:'Replacement',body:'new',attachment:image('new.png')}}, original.id); }}
|
||||
catch (caught) {{ error = caught.message; }}
|
||||
const resumed = await captures.resume(original.id, 'timmy');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
error, titles:captures.list().map(item=>item.title), blobs:[...blobs.keys()],
|
||||
resumedTitle:resumed.title, resumedFilename:resumed.attachment.filename,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"error": "IndexedDB delete failed",
|
||||
"titles": ["Original"],
|
||||
"blobs": ["1"],
|
||||
"resumedTitle": "Original",
|
||||
"resumedFilename": "original.png",
|
||||
}
|
||||
|
||||
|
||||
def test_failed_discard_attachment_cleanup_leaves_draft_visible_and_resumable():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const blobs = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const attachmentStore = {{
|
||||
put: async (id, attachment) => blobs.set(id, attachment),
|
||||
get: async id => blobs.get(id) || null,
|
||||
delete: async () => {{ throw new Error('IndexedDB delete failed'); }},
|
||||
}};
|
||||
(async () => {{
|
||||
const captures = createUnfiledCaptures({{
|
||||
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
|
||||
createId:()=>'capture-1', now:()=>42,
|
||||
}});
|
||||
await captures.save({{title:'Keep this',body:'Important',attachment:{{
|
||||
filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}),
|
||||
}}}});
|
||||
let error = '';
|
||||
try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }}
|
||||
const resumed = await captures.resume('capture-1', 'timmy');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
error, titles:captures.list().map(item=>item.title), resumedTitle:resumed.title,
|
||||
resumedFilename:resumed.attachment.filename,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"error": "IndexedDB delete failed",
|
||||
"titles": ["Keep this"],
|
||||
"resumedTitle": "Keep this",
|
||||
"resumedFilename": "proof.png",
|
||||
}
|
||||
|
||||
|
||||
def test_discard_metadata_write_failure_restores_attachment_and_draft():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const blobs = new Map();
|
||||
let failWrites = false;
|
||||
const storage = {{
|
||||
getItem:k=>values.get(k)||null,
|
||||
setItem:(k,v)=>{{ if (failWrites) throw new Error('Storage write failed'); values.set(k,v); }},
|
||||
}};
|
||||
const attachmentStore = {{
|
||||
put: async (id, attachment) => blobs.set(id, attachment),
|
||||
get: async id => blobs.get(id) || null,
|
||||
delete: async id => blobs.delete(id),
|
||||
}};
|
||||
(async () => {{
|
||||
const captures = createUnfiledCaptures({{
|
||||
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
|
||||
createId:()=>'capture-1', now:()=>42,
|
||||
}});
|
||||
await captures.save({{title:'Keep this',body:'Important',attachment:{{
|
||||
filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}),
|
||||
}}}});
|
||||
failWrites = true;
|
||||
let error = '';
|
||||
try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }}
|
||||
failWrites = false;
|
||||
const resumed = await captures.resume('capture-1', 'timmy');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
error, titles:captures.list().map(item=>item.title), blobIds:[...blobs.keys()],
|
||||
resumedFilename:resumed.attachment.filename,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"error": "Storage write failed",
|
||||
"titles": ["Keep this"],
|
||||
"blobIds": ["capture-1"],
|
||||
"resumedFilename": "proof.png",
|
||||
}
|
||||
|
||||
|
||||
def test_unfiled_capture_resume_requires_matching_confirmed_account_and_removes_only_selected_note():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
@@ -151,18 +352,62 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
|
||||
assert "createUnfiledCaptures({" in html
|
||||
assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html
|
||||
assert "unfiledCaptures.save(captureDraft)" in html
|
||||
assert "await unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
|
||||
assert "const savedCapture = await unfiledCaptures.save(captureDraft)" in html
|
||||
assert "mobileTaskDock.select('queues')" in html
|
||||
assert "data-capture-id=\"' + escapeHtml(item.capture_id) + '\"" in html
|
||||
assert "requestAnimationFrame(() =>" in html
|
||||
assert "savedCard?.scrollIntoView({block:'nearest'})" in html
|
||||
assert "Saved to Drafts. Choose a repository when you’re ready to file it." in html
|
||||
assert "await dependencies.captures.resume(captureId, dependencies.getLogin())" in DRAFT_SESSION.read_text()
|
||||
assert "await createIssueAttachmentController.serialize()" in html
|
||||
assert "createUnfiledAttachmentStore()" in html
|
||||
assert "createIssueAttachmentController.restore(resumed.attachment)" in html
|
||||
assert "await unfiledCaptures.completeResume(resumedUnfiledCaptureId)" in html
|
||||
assert "dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment)" in DRAFT_SESSION.read_text()
|
||||
assert "await unfiledCaptures.completeResume(rUC)" in html
|
||||
assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html
|
||||
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
|
||||
assert "issueCapture.saveDraft(resumed)" in html
|
||||
assert "dependencies.issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
|
||||
assert "item.kind === 'unfiled-issue'" in html
|
||||
assert '.create-issue-actions button { min-height:44px;' in html
|
||||
assert '.draft-card:focus-visible' in html
|
||||
assert 'scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom))' in html
|
||||
assert '@media(max-width:320px)' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_drafts_expose_a_safe_sequential_filing_session():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/draft-filing-session.js"></script>' in html
|
||||
assert 'id="draft-filing-session"' in html
|
||||
assert 'id="draft-filing-progress"' in html
|
||||
assert 'id="previous-draft"' in html
|
||||
assert 'id="skip-draft"' in html
|
||||
assert "dFS.start(item.capture_id)" in html
|
||||
assert "await dFS.nextCapture()" in html
|
||||
assert "await dFS.advance(fS.id)" in html
|
||||
feature = DRAFT_SESSION.read_text()
|
||||
assert "Draft ${state.position} of ${state.total}" in feature
|
||||
assert "qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft'" in feature
|
||||
assert '.draft-filing-session-actions button { min-height:44px;' in html
|
||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="draft-capacity-sheet"' in html
|
||||
assert 'Drafts full — nothing was deleted.' in html
|
||||
assert 'id="review-full-drafts"' in html
|
||||
assert 'id="replace-oldest-draft"' in html
|
||||
assert 'id="keep-editing-draft"' in html
|
||||
assert "showDraftCapacityDialog(unfiledCaptures)" in html
|
||||
feature = (Path(__file__).parents[1] / "frontend" / "draft-capacity-dialog.js").read_text()
|
||||
assert "unfiledCaptures.replaceOldest(draft, oldest.id)" in feature
|
||||
assert '.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh;' in html
|
||||
assert '.draft-capacity-actions button { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_new_opens_capture_first_and_progressively_reveals_filing_fields():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
UPDATE_FOLLOW_UP = ROOT / "frontend" / "update-follow-up.js"
|
||||
CREATE_ISSUE_SHEET = ROOT / "frontend" / "create-issue-sheet.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True, timeout=10
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_follow_up_derives_repository_editable_title_and_canonical_context():
|
||||
script = f"""
|
||||
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
|
||||
const controller = createFollowUp();
|
||||
process.stdout.write(JSON.stringify(controller.draft({{
|
||||
repository: 'stackchain/stackchain-dashboard',
|
||||
title: 'Fix mobile queue',
|
||||
subject_type: 'Pull',
|
||||
url: 'https://forge.example/git/stackchain/stackchain-dashboard/pulls/42',
|
||||
latest_comment: {{ body: 'Please preserve the unread state. Ship this on mobile.' }}
|
||||
}})));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"repository": "stackchain/stackchain-dashboard",
|
||||
"title": "Follow up: Fix mobile queue",
|
||||
"body": (
|
||||
"Source: https://forge.example/git/stackchain/stackchain-dashboard/pulls/42\n\n"
|
||||
"Latest context:\n> Please preserve the unread state. Ship this on mobile."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_follow_up_rejects_unsafe_repository_and_noncanonical_source_url():
|
||||
script = f"""
|
||||
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
|
||||
const controller = createFollowUp();
|
||||
process.stdout.write(JSON.stringify(controller.draft({{
|
||||
repository: '../admin', title: '<script>alert(1)</script>',
|
||||
url: 'javascript:alert(1)', latest_comment: {{body: 'x'.repeat(12000)}}
|
||||
}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["repository"] == ""
|
||||
assert output["title"] == "Follow up: <script>alert(1)</script>"
|
||||
assert "javascript:" not in output["body"]
|
||||
assert len(output["body"]) <= 9500
|
||||
|
||||
|
||||
def test_follow_up_continuation_is_account_bound_and_survives_reload():
|
||||
script = f"""
|
||||
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key)
|
||||
}};
|
||||
let login = 'timmy';
|
||||
const first = createFollowUp({{storage, getLogin:()=>login}});
|
||||
const staged = first.stageSource({{
|
||||
notification_id: 42, repository:'stackchain/stackchain-dashboard', number:665,
|
||||
title:'Unread follow-up source', kind:'update', has_update:true
|
||||
}});
|
||||
const restored = createFollowUp({{storage, getLogin:()=>login}}).source();
|
||||
login = 'alexander';
|
||||
const isolated = createFollowUp({{storage, getLogin:()=>login}}).source();
|
||||
process.stdout.write(JSON.stringify({{staged, restored, isolated}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["staged"]["notification_id"] == 42
|
||||
assert output["restored"] == output["staged"]
|
||||
assert output["isolated"] is None
|
||||
|
||||
|
||||
def test_follow_up_completion_admits_issue_before_durable_read_and_advances_once():
|
||||
script = f"""
|
||||
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key)
|
||||
}};
|
||||
const order = [];
|
||||
const controller = createFollowUp({{storage, getLogin:()=> 'timmy'}});
|
||||
controller.stageSource({{notification_id:42, repository:'o/r', number:7, title:'Source'}});
|
||||
(async () => {{
|
||||
const result = await controller.complete({{
|
||||
admit:async()=>{{ order.push('issue'); return {{item:{{id:'issue:1'}}}}; }},
|
||||
queueRead:async id=>{{ order.push('read:' + id); return {{item:{{id:'read:42'}}}}; }},
|
||||
advance:async source=>{{ order.push('next:' + source.notification_id); return true; }},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{order, result, pending:controller.source()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output == {
|
||||
"order": ["issue", "read:42", "next:42"],
|
||||
"result": {"admission": {"item": {"id": "issue:1"}}, "advanced": True},
|
||||
"pending": None,
|
||||
}
|
||||
|
||||
|
||||
def test_follow_up_completion_preserves_source_when_issue_admission_fails():
|
||||
script = f"""
|
||||
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key)
|
||||
}};
|
||||
const order = [];
|
||||
const controller = createFollowUp({{storage, getLogin:()=> 'timmy'}});
|
||||
controller.stageSource({{notification_id:42, repository:'o/r', number:7, title:'Source'}});
|
||||
(async () => {{
|
||||
try {{
|
||||
await controller.complete({{
|
||||
admit:async()=>{{ order.push('issue'); throw new Error('disk full'); }},
|
||||
queueRead:async()=>order.push('read'), advance:async()=>order.push('next'),
|
||||
}});
|
||||
}} catch (error) {{
|
||||
process.stdout.write(JSON.stringify({{order, error:error.message, pending:controller.source()}}));
|
||||
}}
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["order"] == ["issue"]
|
||||
assert output["error"] == "disk full"
|
||||
assert output["pending"]["notification_id"] == 42
|
||||
|
||||
|
||||
def test_follow_up_staging_never_silently_overwrites_an_existing_capture():
|
||||
script = f"""
|
||||
const createCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key)
|
||||
}};
|
||||
const capture = createCapture({{fetchJson:async()=>[], storage}});
|
||||
capture.saveDraft({{repository:'o/existing', title:'Existing draft', body:'Keep me'}});
|
||||
const state = capture.stageFollowUp({{repository:'o/new', title:'Follow up', body:'Source: https://example.test/1'}});
|
||||
const before = capture.loadDraft();
|
||||
const accepted = capture.acceptFollowUp();
|
||||
process.stdout.write(JSON.stringify({{state, before, accepted, pending:capture.pendingFollowUp()}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"state": {"status": "conflict"},
|
||||
"before": {
|
||||
"repository": "o/existing",
|
||||
"title": "Existing draft",
|
||||
"body": "Keep me",
|
||||
"labelIds": [],
|
||||
},
|
||||
"accepted": {
|
||||
"repository": "o/new",
|
||||
"title": "Follow up",
|
||||
"body": "Source: https://example.test/1",
|
||||
"labelIds": [],
|
||||
},
|
||||
"pending": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_sheet_wires_phone_safe_follow_up_without_marking_read():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'name="stackchain-feature-issue-capture"' in html
|
||||
assert 'id="create-update-follow-up"' in html
|
||||
assert '>Create follow-up</button>' in html
|
||||
assert "issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail))" in html
|
||||
assert "qs('#create-update-follow-up').addEventListener('click'" in html
|
||||
assert "markNotificationRead" not in html.split("qs('#create-update-follow-up').addEventListener('click'", 1)[1].split("});", 1)[0]
|
||||
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_follow_up_capture_offers_durable_create_and_next_flow():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="create-follow-up-next"' in html
|
||||
assert '>Create follow-up & next</button>' in html
|
||||
assert "updateFollowUp.stageSource(source.item)" in html
|
||||
assert "event.submitter?.id === 'create-follow-up-next'" in html
|
||||
assert "queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId)" in html
|
||||
assert "advance: source => notificationReader.acceptReadAndNext(lastMyWork, source)" in html
|
||||
assert ".create-issue-actions button { min-height:44px;" in html
|
||||
Reference in new issue
Block a user