Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51f4223f4b | ||
|
|
241f0f3a2a | ||
|
|
34d5f86d53 | ||
|
|
3fde7cf3ad | ||
|
|
6913fc8257 | ||
|
|
437da72f3d | ||
|
|
9a2b8d97bd | ||
|
|
1e4f101219 | ||
|
|
c0a02a05d8 | ||
|
|
246f995b56 | ||
|
|
db28cc5690 | ||
|
|
26aa12019e | ||
|
|
1baa9c1f91 | ||
|
|
d64c646b68 | ||
|
|
021af60cf3 | ||
|
|
09dbdb9961 | ||
|
|
484dcc6b9c | ||
|
|
7571a4aa2d | ||
|
|
dc68305dcb | ||
|
|
386acda1dc | ||
|
|
a2606f1dc2 | ||
|
|
a2c91e0296 | ||
|
|
8148545295 | ||
|
|
70c8ea6584 | ||
|
|
f30143b5ea | ||
|
|
cf76bc3636 | ||
|
|
0fc152f623 | ||
|
|
377fd889c5 | ||
|
|
0a4c9a0ae8 | ||
|
|
1a4f930b50 | ||
|
|
49bbeadc48 | ||
|
|
5470e48e26 | ||
|
|
0fcb7f5152 | ||
|
|
0f30ca94ab | ||
|
|
468676d2f4 | ||
|
|
40623f939c | ||
|
|
110fd860b3 | ||
|
|
edf5d3fda4 | ||
|
|
e395878f92 | ||
|
|
f48b9b5a6c | ||
|
|
971656b76c | ||
|
|
48ec66cc30 |
No files matched your search
@@ -15,6 +15,8 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with: { python-version: "3.11" }
|
||||
- run: pip install -r requirements.txt
|
||||
- run: pip install -r requirements-audit.txt
|
||||
- run: python3 -m pip_audit -r requirements.txt --strict
|
||||
- run: python3 -m pytest tests/ -q
|
||||
|
||||
build-release:
|
||||
@@ -55,7 +57,7 @@ jobs:
|
||||
pip install -r requirements-e2e.txt
|
||||
python3 -m playwright install --with-deps chromium
|
||||
- name: Exercise packaged mobile work journeys
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py -q
|
||||
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -26,6 +26,7 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.app-menu { margin-left:auto; }
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
#open-insights { display:none; }
|
||||
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; }
|
||||
@@ -45,6 +46,11 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.active-devices-header h2, .active-devices-header p { margin-top:0; }
|
||||
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
|
||||
.security-section-nav { position:sticky; top:-18px; z-index:2; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:0 -2px; padding:10px 2px; background:#0b1526; }
|
||||
.security-section-nav button { min-height:44px; min-width:0; }
|
||||
.security-section-nav button[aria-current="page"] { border-color:#55d6be; color:#55d6be; }
|
||||
.security-activity, .security-devices, .enrolled-passkeys { scroll-margin-top:64px; }
|
||||
.security-devices { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
|
||||
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
|
||||
.device-setup-sheet { position:fixed; inset:0; z-index:95; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.device-setup-sheet[hidden] { display:none; }
|
||||
@@ -59,6 +65,10 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
|
||||
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour, #push-deadline-days { min-height:44px; }
|
||||
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
|
||||
.device-storage { display:grid; gap:12px; margin:14px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#0d1d31; }
|
||||
.device-storage h3, .device-storage p { margin:0 0 5px; }
|
||||
.device-storage-actions { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.device-storage-actions button { min-height:44px; flex:1 1 180px; }
|
||||
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
|
||||
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
|
||||
.device-readiness-summary { color:#bfdbfe; font-weight:700; }
|
||||
@@ -76,6 +86,7 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.security-activity-header h3, .security-activity-header p { margin:0 0 6px; }
|
||||
.security-activity-list { display:grid; gap:8px; margin:12px 0; }
|
||||
.security-event { padding:12px; border:1px solid #243d5d; border-radius:12px; background:#0d1c30; }
|
||||
.security-event button { min-height:44px; margin-top:10px; }
|
||||
.authentication-alert { border-color:#d69e2e; background:#241b09; }
|
||||
.authentication-alert strong { color:#f6c453; }
|
||||
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
|
||||
@@ -91,6 +102,8 @@ button:hover { filter: brightness(1.15); }
|
||||
.panel[open] > summary { margin-bottom: 8px; }
|
||||
aside, section { position: relative; z-index: 1; }
|
||||
main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
|
||||
.insights-shell { display:contents; }
|
||||
.insights-header { display:none; }
|
||||
@media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
|
||||
.sidebar { display:flex; flex-direction:column; gap: 12px; }
|
||||
.stack { display:flex; flex-direction:column; gap:10px; }
|
||||
@@ -258,12 +271,39 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.today-recap-totals { margin:12px 0; padding:12px; border-radius:10px; background:#10233d; font-weight:700; }
|
||||
.today-recap-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-recap-actions button { min-height:44px; width:100%; }
|
||||
.today-wrap-up-sheet { position:fixed; inset:0; z-index:89; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-wrap-up-sheet[hidden] { display:none; }
|
||||
.today-wrap-up-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 #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-wrap-up-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-wrap-up-header h2 { margin:.2rem 0; }
|
||||
.today-wrap-up-header button { min-height:44px; min-width:44px; }
|
||||
.today-wrap-up-items { display:grid; gap:8px; margin:14px 0; }
|
||||
.today-wrap-up-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:12px; min-width:0; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233d; overflow-wrap:anywhere; }
|
||||
.today-wrap-up-item span { display:block; }
|
||||
.today-wrap-up-item label { display:flex; align-items:center; min-height:44px; gap:8px; font-weight:700; }
|
||||
.today-wrap-up-item input { width:24px; min-height:24px; }
|
||||
.today-wrap-up-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-wrap-up-actions button { min-height:44px; width:100%; }
|
||||
.today-handoff-dialog { box-sizing:border-box; width:min(620px,100%); max-width:none; max-height:100dvh; margin:auto auto 0; padding:0; color:#e8f1ff; border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-handoff-dialog::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-handoff-panel { max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); }
|
||||
.today-handoff-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-handoff-panel h2 { margin:.2rem 0; }
|
||||
.today-handoff-panel header button { min-height:44px; min-width:44px; }
|
||||
.today-handoff-items { display:grid; gap:8px; margin:14px 0; }
|
||||
.today-handoff-item { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:12px; padding:12px; border:1px solid #29486c; border-radius:12px; background:#101f35; }
|
||||
.today-handoff-item span { display:block; }
|
||||
.today-handoff-item label { display:flex; align-items:center; min-height:44px; gap:8px; font-weight:700; }
|
||||
.today-handoff-item input { width:24px; min-height:24px; }
|
||||
.today-handoff-actions { position:sticky; bottom:0; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-handoff-actions button { min-height:44px; width:100%; }
|
||||
@media (max-width:420px) {
|
||||
.today-interruption-actions { grid-template-columns:1fr; }
|
||||
.today-recap-actions { grid-template-columns:1fr; }
|
||||
.today-recap-row { grid-template-columns:1fr; }
|
||||
.today-recap-row input { width:min(100%,8rem); }
|
||||
.today-recap-variance { grid-column:1; }
|
||||
.today-wrap-up-item { grid-template-columns:1fr; }
|
||||
}
|
||||
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-readiness-sheet[hidden] { display:none; }
|
||||
@@ -905,6 +945,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.app-menu:not([open]) > .app-menu-panel { display:none; }
|
||||
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
|
||||
.app-menu-panel button { min-height:44px; width:100%; }
|
||||
#open-insights { display:block; }
|
||||
.insights-shell { display:none; }
|
||||
.insights-shell[data-mobile-open="true"] { position:fixed; inset:0; z-index:90; display:grid; align-content:start; gap:12px; overflow:auto; overflow-x:hidden; padding:max(12px,env(safe-area-inset-top)) max(12px,env(safe-area-inset-right)) max(12px,env(safe-area-inset-bottom)) max(12px,env(safe-area-inset-left)); background:#050c15; }
|
||||
.insights-header { position:sticky; top:0; z-index:2; display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; }
|
||||
.insights-header h2, .insights-header p { margin:0 0 4px; }
|
||||
.insights-header button { min-height:44px; flex:none; }
|
||||
.device-setup-panel { width:100%; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.device-setup-step { grid-template-columns:1fr; }
|
||||
.device-setup-action { width:100%; }
|
||||
@@ -991,6 +1037,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.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-delivery-recovery { box-sizing:border-box; width:100%; max-width:none; max-height:100dvh; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.mobile-delivery-recovery::backdrop { background:rgba(3,9,18,.78); }
|
||||
.mobile-delivery-recovery-panel { box-sizing:border-box; display:grid; gap:12px; width:100%; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); overflow-wrap:anywhere; }
|
||||
.mobile-delivery-recovery-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.mobile-delivery-recovery-panel h2, .mobile-delivery-recovery-panel p { margin:0; }
|
||||
.mobile-delivery-recovery-panel button { min-height:48px; }
|
||||
.mobile-delivery-recovery-panel > #mobile-delivery-recovery-action { width:100%; font-weight:800; }
|
||||
.delivery-recovery-reason { padding:12px; border:1px solid #31577f; border-radius:12px; background:#0b1c30; }
|
||||
.mobile-start-day { display:grid; gap:12px; max-width:100%; overflow-wrap:anywhere; margin-top:12px; padding:14px; border:1px solid #31577f; border-radius:14px; background:linear-gradient(135deg,#173b64,#102641); }
|
||||
.mobile-start-day h3, .mobile-start-day p { margin:0; }
|
||||
.mobile-start-day p + p { margin-top:4px; }
|
||||
@@ -1021,3 +1075,47 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
/* One-handed live-data recovery on the mobile Home surface. */
|
||||
.mobile-pull-refresh {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.mobile-pull-refresh {
|
||||
position: sticky;
|
||||
top: calc(3.25rem + env(safe-area-inset-top));
|
||||
z-index: 8;
|
||||
display: block;
|
||||
width: max-content;
|
||||
max-width: calc(100vw - 2rem);
|
||||
margin: 0 auto -2rem;
|
||||
padding: .45rem .8rem;
|
||||
border: 1px solid rgba(125, 211, 252, .45);
|
||||
border-radius: 999px;
|
||||
background: rgba(11, 18, 32, .96);
|
||||
color: #dbeafe;
|
||||
font-size: .78rem;
|
||||
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .28);
|
||||
transform: translateY(.5rem);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mobile-pull-refresh[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-pull-refresh[data-state="ready"],
|
||||
.mobile-pull-refresh[data-state="success"] {
|
||||
border-color: rgba(52, 211, 153, .7);
|
||||
}
|
||||
|
||||
.mobile-pull-refresh[data-state="error"] {
|
||||
border-color: rgba(251, 113, 133, .75);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) and (prefers-reduced-motion: no-preference) {
|
||||
.mobile-pull-refresh {
|
||||
transition: transform 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
}
|
||||
+116
-22
@@ -1,4 +1,5 @@
|
||||
(async function(){
|
||||
const workspaceLifecycle = await loadWorkspace({ document, window });
|
||||
const qs = (s, el=document) => el.querySelector(s);
|
||||
const fmt = (d) => new Date(d).toLocaleString();
|
||||
const cardPlanning = createCardPlanning(document);
|
||||
@@ -26,6 +27,7 @@
|
||||
actions:qs('#issue-planning'),
|
||||
},
|
||||
planning:qs('#issue-planning'),
|
||||
onSectionChange:(section, options) => workRoute.section(section, options),
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
mobileIssueDetailNavigation.start();
|
||||
@@ -40,6 +42,7 @@
|
||||
review:qs('#pull-review'),
|
||||
},
|
||||
beforeNavigate:{review(target) { target.open = true; }},
|
||||
onSectionChange:(section, options) => workRoute.section(section, options),
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
mobilePullDetailNavigation.start();
|
||||
@@ -87,7 +90,23 @@
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
async function recoverMobileDelivery(item) {
|
||||
if (!item?.outbox_id || !activeFlushLogin) throw new Error('Sign in again to retry.');
|
||||
const authored = item.kind === 'authored-outbox';
|
||||
const result = await (authored ? authoredOutbox : issueOutbox).retry(item.outbox_id, activeFlushLogin);
|
||||
(authored ? applyAuthoredOutboxResult : applyOutboxResult)(result);
|
||||
}
|
||||
let mobileQueueCounts = {};
|
||||
const mobileDeliveryRecovery = createMobileDeliveryRecovery({
|
||||
getItems: () => draftInbox.partition(lastDrafts).deliveries,
|
||||
getIndex: item => lastDrafts.indexOf(item),
|
||||
activate: recoverMobileDelivery,
|
||||
beforeOpen: () => selectMobileQueue('draft'),
|
||||
onComplete: () => {
|
||||
if (!mobileStartDay.completePhase('delivery')) showMobileQueueCompletion('Delivery', true);
|
||||
},
|
||||
});
|
||||
mobileDeliveryRecovery.start();
|
||||
function openFiledFollowUp() {
|
||||
selectMobileQueue('filed');
|
||||
const target = filedFollowUpTarget(completedFiledReview.visible(lastMyWork));
|
||||
@@ -101,6 +120,7 @@
|
||||
return target.kind === 'update' ? 'opened-update' : 'opened-issue';
|
||||
}
|
||||
const mobileQueueLauncher = createMobileQueueLauncher({
|
||||
openDelivery: () => mobileDeliveryRecovery.open(),
|
||||
openToday: () => mobileWorkEntry.open(),
|
||||
openAgenda: openAgendaSession,
|
||||
openUpdates: openUpdateTriage,
|
||||
@@ -204,6 +224,20 @@
|
||||
},
|
||||
});
|
||||
mobileTaskDock.start();
|
||||
const mobileInsights = createMobileInsights({
|
||||
root: qs('#insights-sheet'),
|
||||
launcher: qs('#open-insights'),
|
||||
closeButton: qs('#close-insights'),
|
||||
dock: qs('#mobile-task-dock'),
|
||||
hud: qs('[data-mobile-today-hud]'),
|
||||
backgrounds: [qs('header'), qs('#my-work')],
|
||||
menu: qs('.app-menu'),
|
||||
history: window.history,
|
||||
location: window.location,
|
||||
eventTarget: window,
|
||||
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
||||
});
|
||||
mobileInsights.start();
|
||||
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
|
||||
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
|
||||
let liveMode = true;
|
||||
@@ -323,7 +357,9 @@
|
||||
const marker = `stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`;
|
||||
if (!localStorage.getItem(marker)) {
|
||||
localStorage.setItem(marker, '1');
|
||||
setTimeout(() => openPlanToday(qs('#plan-today')), 0);
|
||||
setTimeout(() => {
|
||||
if (!workSession.checkpointed()) openPlanToday(qs('#plan-today'));
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
onStatus: (state, detail = {}) => {
|
||||
@@ -337,19 +373,36 @@
|
||||
},
|
||||
});
|
||||
todaySync.startLifecycle({ window, document });
|
||||
const todayHandoff = createTodayHandoff({
|
||||
storage:localStorage,
|
||||
getLogin:() => planningOwnerLogin,
|
||||
identity:item => todayWork.identity(item),
|
||||
items:() => [...todayMyWork, ...activeMyWork],
|
||||
todayWork,
|
||||
todaySync,
|
||||
});
|
||||
const todayHandoffView = todayHandoff.mount({ qs, escapeHtml,
|
||||
onComplete:() => refreshMyWorkView(),
|
||||
});
|
||||
const promptTodayHandoff = () => todayHandoffView.open();
|
||||
const laterWork = createLaterWork({
|
||||
storage: localStorage,
|
||||
getLogin: () => planningOwnerLogin,
|
||||
onChange: (action, itemId, wakeAt) => {
|
||||
if (laterSync.enqueue(action, itemId, wakeAt)) laterSync.flush();
|
||||
onChange: (action, itemId, wakeAt, handoff) => {
|
||||
if (laterSync.enqueue(action, itemId, wakeAt, handoff)) laterSync.flush();
|
||||
},
|
||||
onExpire: ids => {
|
||||
onExpire: (ids, handoffs) => {
|
||||
if (handoffs.length) {
|
||||
todayHandoff.capture(handoffs);
|
||||
todayHandoffView.reset();
|
||||
}
|
||||
const queued = ids.map(id => laterSync.enqueue('restore', id)).every(Boolean);
|
||||
if (queued) laterSync.flush();
|
||||
},
|
||||
onWake: () => {
|
||||
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
|
||||
refreshMyWorkView();
|
||||
setTimeout(promptTodayHandoff, 0);
|
||||
},
|
||||
});
|
||||
const laterSync = createLaterSync({
|
||||
@@ -1375,6 +1428,7 @@
|
||||
},
|
||||
replyComposer:qs('#update-reply'),
|
||||
jumpToNewActivity:() => updateReadPosition.jump(),
|
||||
onSectionChange:(section, options) => workRoute.section(section, options),
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
mobileUpdateDetailNavigation.start();
|
||||
@@ -1388,6 +1442,7 @@
|
||||
history:qs('#review-history-workspace'),
|
||||
},
|
||||
summaryComposer:qs('#review-summary'),
|
||||
onSectionChange:(section, options) => workRoute.section(section, options),
|
||||
prefersReducedMotion:() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
mobileReviewDetailNavigation.start();
|
||||
@@ -1631,6 +1686,25 @@
|
||||
workRoute.open(routedWorkItem(item), options);
|
||||
}
|
||||
|
||||
function navigateWorkSection(kind, section) {
|
||||
if (['issue', 'filed'].includes(kind)) {
|
||||
mobileIssueDetailNavigation.navigate(section, { focus:false });
|
||||
} else if (kind === 'pull') {
|
||||
mobilePullDetailNavigation.navigate(section, { focus:false });
|
||||
} else if (kind === 'update') {
|
||||
mobileUpdateDetailNavigation.navigate(section, { focus:false });
|
||||
} else if (kind === 'review') {
|
||||
mobileReviewDetailNavigation.navigate(section, { focus:false });
|
||||
}
|
||||
}
|
||||
|
||||
async function openRoutedWorkSection(item) {
|
||||
if (item.kind === 'update') await notificationReader.open(item, lastMyWork);
|
||||
else if (item.kind === 'review') await openReviewSheet(item, reviewTrigger);
|
||||
else if (item.kind === 'issue' || item.kind === 'filed') await openIssueSheet(item, issueTrigger);
|
||||
else if (item.kind === 'pull') await openPullSheet(item, pullTrigger);
|
||||
}
|
||||
|
||||
const workRoute = createWorkRoute.createController({
|
||||
location: window.location,
|
||||
history: window.history,
|
||||
@@ -1648,14 +1722,17 @@
|
||||
qs('#retry-work-route').hidden = true;
|
||||
qs('#my-work-action-status').textContent = 'Loading shared work item…';
|
||||
},
|
||||
onOpen: item => {
|
||||
onOpen: async item => {
|
||||
qs('#retry-work-route').hidden = true;
|
||||
qs('#my-work-action-status').textContent = '';
|
||||
closeOpenWorkSheets();
|
||||
if (item.kind === 'update') notificationReader.open(item, lastMyWork);
|
||||
else if (item.kind === 'review') openReviewSheet(item, reviewTrigger);
|
||||
else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);
|
||||
else if (item.kind === 'pull') openPullSheet(item, pullTrigger);
|
||||
await openRoutedWorkSection(item);
|
||||
const route = createWorkRoute.parse(window.location.hash);
|
||||
if (route?.section === item.section) navigateWorkSection(item.kind, item.section);
|
||||
},
|
||||
onSection: (section, options) => {
|
||||
const route = createWorkRoute.parse(window.location.hash);
|
||||
if (options.restore) navigateWorkSection(route?.kind, section);
|
||||
},
|
||||
onQueue: openWorkQueueRoute,
|
||||
onClose: () => {
|
||||
@@ -1765,11 +1842,18 @@
|
||||
interruptionPrompt.resolve(button.dataset.todayInterruption)
|
||||
);
|
||||
});
|
||||
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
|
||||
onComplete:() => {
|
||||
todayRecapView['completeReplan']();
|
||||
refreshMyWorkView();
|
||||
warmTodayOffline();
|
||||
},
|
||||
});
|
||||
const todayRecapView = setupTodayRecap(
|
||||
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
|
||||
() => planningOwnerLogin,
|
||||
identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null,
|
||||
actualMinutes => openPlanToday(qs('#plan-today'), true, actualMinutes)
|
||||
actualMinutes => todayWrapUpView.open(todayMyWork, actualMinutes)
|
||||
);
|
||||
function updateDetailDeferLabels(active) {
|
||||
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
|
||||
@@ -2477,7 +2561,7 @@
|
||||
|
||||
function renderPlanIssueDependencies(detail, loading = false) {
|
||||
const preview = planTodayPreview.snapshot();
|
||||
const assignedIssue = Boolean(selectedIssue && selectedIssueDetail === detail);
|
||||
const assignedIssue = Boolean(!preview.open && selectedIssue && selectedIssueDetail === detail);
|
||||
if (!assignedIssue && (!preview.open || preview.item?.kind !== 'issue')) return;
|
||||
const panel = qs('#issue-blockers');
|
||||
const list = qs('#issue-blocker-list');
|
||||
@@ -2957,6 +3041,7 @@
|
||||
counts.today = todayMyWork.length;
|
||||
counts.later = laterMyWork.length;
|
||||
counts.draft = lastDrafts.length;
|
||||
counts.delivery = draftInbox.partition(lastDrafts).actionable;
|
||||
if (!launchFilterResolved) {
|
||||
selectedWorkFilter = mobileLaunch.chooseFilter({
|
||||
saved: savedWorkFilter, today: counts.today, attention: counts.attention,
|
||||
@@ -2972,7 +3057,10 @@
|
||||
if (element) element.textContent = count;
|
||||
});
|
||||
mobileQueueCounts = counts;
|
||||
mobileStartDay.reconcile({authoritative:authoritativeMyWorkRefresh});
|
||||
mobileStartDay.reconcile({
|
||||
authoritative:authoritativeMyWorkRefresh,
|
||||
authoritativePhases:['delivery'],
|
||||
});
|
||||
mobileStartDay.render();
|
||||
mobileTaskDock.updateQueues(counts);
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode());
|
||||
@@ -3005,6 +3093,7 @@
|
||||
}
|
||||
if (reconcileSession && workSession.active()) workSession.reconcile();
|
||||
updateWorkSessionActions();
|
||||
if (todayHandoff.pending().length) setTimeout(promptTodayHandoff, 0);
|
||||
}
|
||||
|
||||
function activeWorkStreams() {
|
||||
@@ -3097,7 +3186,7 @@
|
||||
'<div class="draft-actions">' + outboxActions + '</div></article>';
|
||||
};
|
||||
const deliverySummary = '<section class="delivery-center" aria-labelledby="delivery-center-title">' +
|
||||
'<div><h3 id="delivery-center-title">Delivery center</h3>' +
|
||||
'<div><h3 id="delivery-center-title" tabindex="-1">Delivery center</h3>' +
|
||||
'<p class="small">Waiting <strong data-delivery-count="waiting">' + deliveryCenter.counts.waiting + '</strong> · ' +
|
||||
'Sending <strong data-delivery-count="sending">' + deliveryCenter.counts.sending + '</strong> · ' +
|
||||
'Needs attention <strong data-delivery-count="attention">' + deliveryCenter.counts.attention + '</strong> · ' +
|
||||
@@ -3116,14 +3205,14 @@
|
||||
const button = event.currentTarget;
|
||||
if (!activeFlushLogin || !deliveryCenter.retryable.length) return;
|
||||
button.disabled = true;
|
||||
qs('#my-work-action-status').textContent = 'Retrying safe waiting deliveries…';
|
||||
qs('#my-work-action-status').textContent = 'Retrying waiting deliveries…';
|
||||
const [issueResult, authoredResult] = await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)]);
|
||||
applyOutboxResult(issueResult);
|
||||
applyAuthoredOutboxResult(authoredResult);
|
||||
qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.';
|
||||
qs('#my-work-action-status').textContent = 'Waiting deliveries retried; attention stayed queued.';
|
||||
});
|
||||
list.querySelectorAll('.draft-resume').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
button.onclick=async()=>{
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item) return;
|
||||
if (item.kind === 'unfiled-issue') {
|
||||
@@ -3132,7 +3221,7 @@
|
||||
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
||||
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
||||
else if (item.route) workRoute.open(item.route);
|
||||
});
|
||||
};
|
||||
});
|
||||
list.querySelectorAll('.draft-continue').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
@@ -3179,7 +3268,7 @@
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-review-checklist').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
button.onclick=async()=>{
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
const queued = authoredOutbox.list().find(candidate => candidate.id === item?.outbox_id);
|
||||
if (!item?.checklist_conflict || !queued || !activeFlushLogin) return;
|
||||
@@ -3212,7 +3301,7 @@
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
list.querySelectorAll('.draft-authorize').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
@@ -3233,12 +3322,12 @@
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-copy').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
button.onclick=async()=>{
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item?.copy_text) return;
|
||||
await navigator.clipboard.writeText(item.copy_text);
|
||||
qs('#my-work-action-status').textContent = 'Queued content copied without sending it.';
|
||||
});
|
||||
};
|
||||
});
|
||||
list.querySelectorAll('.draft-discard').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
@@ -3257,6 +3346,7 @@
|
||||
qs('#my-work-action-status').textContent = 'Draft discarded.';
|
||||
});
|
||||
});
|
||||
mobileDeliveryRecovery.render();
|
||||
}
|
||||
|
||||
function updateWorkPaginationControls() {
|
||||
@@ -7141,6 +7231,7 @@
|
||||
intervalMs: 8000,
|
||||
});
|
||||
function load() { return contextPoller.refresh({ force: true }); }
|
||||
createMobilePullRefresh({refresh:load}).start();
|
||||
const liveDataStatusSheet = qs('#live-data-status-sheet');
|
||||
const liveDataStatusTrigger = qs('#open-live-data-status');
|
||||
let latestLiveFreshness = {};
|
||||
@@ -7289,6 +7380,9 @@
|
||||
flushAuthored: flushAuthoredOutbox,
|
||||
flushNotificationReads: flushNotificationReadOutbox,
|
||||
});
|
||||
const reconnectAfterOnline = () => setTimeout(reconnectOutboxes, 500);
|
||||
window.addEventListener('online', reconnectAfterOnline);
|
||||
workspaceLifecycle.replayOnline(reconnectAfterOnline);
|
||||
async function setOfflineWorkEnabled(enabled) {
|
||||
keepWorkOffline.checked = enabled;
|
||||
offlineWorkStore.setEnabled(enabled);
|
||||
@@ -7346,7 +7440,6 @@
|
||||
updateDeliveryReceiptControls();
|
||||
if (!navigator.onLine) await showOfflineStatus();
|
||||
window.addEventListener('offline', showOfflineStatus);
|
||||
window.addEventListener('online', reconnectOutboxes);
|
||||
|
||||
qs('#refresh').addEventListener('click', load);
|
||||
qs('#plan-today').addEventListener('click', event => openPlanToday(event.currentTarget));
|
||||
@@ -7700,6 +7793,7 @@
|
||||
await controller.enableDeadline();
|
||||
},
|
||||
});
|
||||
await createDeviceStorage.mount(document).start();
|
||||
await deviceSetup.start();
|
||||
return deviceSetup;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else {
|
||||
root.createDeviceStorage = factory;
|
||||
root.createDeviceStorage.mount = document => factory({
|
||||
summary:document.querySelector('#device-storage-summary'),
|
||||
detail:document.querySelector('#device-storage-detail'),
|
||||
clearCachesButton:document.querySelector('#clear-device-caches'),
|
||||
clearAllButton:document.querySelector('#clear-private-device-data'),
|
||||
localStorage:root.localStorage,
|
||||
sessionStorage:root.sessionStorage,
|
||||
caches:root.caches,
|
||||
storageManager:root.navigator?.storage,
|
||||
privateDatabases:root.stackchainPrivateDatabases,
|
||||
inspectPrivateDatabases:root.inspectStackchainPrivateDatabases,
|
||||
clearPrivateDeviceData:root.stackchainPrivateDeviceData,
|
||||
});
|
||||
}
|
||||
})(typeof self !== 'undefined' ? self : this, function createDeviceStorage(options) {
|
||||
let privateItemCount = 0;
|
||||
let privateRecordCount = 0;
|
||||
let inventoryUnavailable = false;
|
||||
let fullClearArmed = false;
|
||||
const ownedCaches = async () => (await options.caches?.keys?.() || [])
|
||||
.filter(name => name.startsWith('stackchain-dashboard-'));
|
||||
|
||||
function ownedStorageCount(storage) {
|
||||
if (!storage) return 0;
|
||||
let count = 0;
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
if (storage.key(index)?.startsWith('stackchain.')) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function megabytes(value) {
|
||||
return Math.round((Number(value) || 0) / (1024 * 1024));
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let estimate = null;
|
||||
try { estimate = await options.storageManager?.estimate?.(); }
|
||||
catch (_error) { /* Storage estimates are optional. */ }
|
||||
options.summary.textContent = estimate?.quota
|
||||
? `${megabytes(estimate.usage)} MB of ${megabytes(estimate.quota)} MB browser storage used.`
|
||||
: 'Browser storage usage is unavailable.';
|
||||
const itemCount = ownedStorageCount(options.localStorage)
|
||||
+ (options.sessionStorage === options.localStorage ? 0 : ownedStorageCount(options.sessionStorage));
|
||||
privateItemCount = itemCount;
|
||||
const inventory = await options.inspectPrivateDatabases?.(options.privateDatabases)
|
||||
|| { recordCount: 0, unavailable: true };
|
||||
privateRecordCount = inventory.recordCount;
|
||||
inventoryUnavailable = inventory.unavailable;
|
||||
const cacheCount = (await ownedCaches()).length;
|
||||
options.detail.textContent = `${itemCount} private browser item${itemCount === 1 ? '' : 's'} · `
|
||||
+ `${inventoryUnavailable ? 'private work status unknown' : `${privateRecordCount} private work record${privateRecordCount === 1 ? '' : 's'}`} · `
|
||||
+ `${cacheCount} cached app cop${cacheCount === 1 ? 'y' : 'ies'}`;
|
||||
return { itemCount, privateRecordCount, inventoryUnavailable, cacheCount };
|
||||
}
|
||||
|
||||
async function clearCaches() {
|
||||
options.clearCachesButton.disabled = true;
|
||||
try {
|
||||
await Promise.all((await ownedCaches()).map(name => options.caches.delete(name)));
|
||||
await refresh();
|
||||
options.summary.textContent = 'Cached app copies cleared. Private work was kept.';
|
||||
} catch (error) {
|
||||
options.summary.textContent = `Cached copies could not be cleared: ${error.message}`;
|
||||
} finally {
|
||||
options.clearCachesButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
if ((privateItemCount > 0 || privateRecordCount > 0 || inventoryUnavailable) && !fullClearArmed) {
|
||||
fullClearArmed = true;
|
||||
options.clearAllButton.textContent = 'Confirm: clear private work';
|
||||
options.summary.textContent = 'Private drafts or queued work may not be synced. Press confirm to clear them from this device.';
|
||||
return;
|
||||
}
|
||||
options.clearAllButton.disabled = true;
|
||||
try {
|
||||
await options.clearPrivateDeviceData();
|
||||
privateItemCount = 0;
|
||||
fullClearArmed = false;
|
||||
options.clearAllButton.textContent = 'Clear all private data';
|
||||
options.detail.textContent = '0 private browser items · 0 private work records · 0 cached app copies';
|
||||
options.summary.textContent = 'All Stackchain private data was cleared from this device.';
|
||||
} catch (error) {
|
||||
options.summary.textContent = `Private data was not fully cleared: ${error.message}`;
|
||||
} finally {
|
||||
options.clearAllButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
options.clearCachesButton.addEventListener('click', clearCaches);
|
||||
options.clearAllButton.addEventListener('click', clearAll);
|
||||
return refresh();
|
||||
}
|
||||
|
||||
return { refresh, start };
|
||||
});
|
||||
+9
-2
@@ -240,7 +240,14 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
}
|
||||
|
||||
function partition(items = list()) {
|
||||
const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox');
|
||||
const isDelivery = item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
||||
const needsAction = item => isDelivery(item) && (
|
||||
item.status === 'attention' || item.status === 'authorization' || item.status === 'completion' ||
|
||||
item.delivery_state === 'uncertain' || item.quarantined
|
||||
);
|
||||
const deliveries = items.filter(isDelivery).sort((left, right) =>
|
||||
Number(needsAction(right)) - Number(needsAction(left))
|
||||
);
|
||||
const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox');
|
||||
const counts = deliveries.reduce((summary, item) => {
|
||||
const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' :
|
||||
@@ -251,7 +258,7 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat
|
||||
const retryable = deliveries.filter(item =>
|
||||
item.status === 'queued' && !item.authorization_required && !item.quarantined && item.delivery_state !== 'uncertain'
|
||||
);
|
||||
return { drafts, deliveries, counts, retryable };
|
||||
return { drafts, deliveries, counts, retryable, actionable:deliveries.filter(needsAction).length };
|
||||
}
|
||||
|
||||
function deliveryLabel(item) {
|
||||
|
||||
+94
-15
@@ -16,6 +16,7 @@
|
||||
<details class="app-menu">
|
||||
<summary id="app-menu-toggle" aria-label="Open dashboard menu">Menu</summary>
|
||||
<div class="app-menu-panel">
|
||||
<button id="open-insights" type="button" aria-controls="insights-sheet">Insights</button>
|
||||
<button id="refresh">Refresh</button>
|
||||
<button id="open-palette">Command</button>
|
||||
<button id="open-device-setup" type="button">Device setup</button>
|
||||
@@ -47,22 +48,15 @@
|
||||
<div id="active-devices-sheet" class="active-devices-sheet" hidden>
|
||||
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices and passkeys">
|
||||
<div class="active-devices-header">
|
||||
<div><h2>Active devices</h2><p class="small muted">Review signed-in devices and remove access you no longer trust.</p></div>
|
||||
<div><h2>Security Center</h2><p class="small muted">Investigate activity, remove untrusted access, and verify the result.</p></div>
|
||||
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
|
||||
</div>
|
||||
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
|
||||
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
|
||||
<div id="active-devices-list" class="active-devices-list"></div>
|
||||
<section class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
|
||||
<div class="enrolled-passkeys-header">
|
||||
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
|
||||
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
|
||||
<p class="small muted">A recovery token will be required after sign-out if you remove this device’s passkey.</p>
|
||||
</div>
|
||||
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
|
||||
</section>
|
||||
<section class="security-activity" aria-labelledby="security-activity-title">
|
||||
<nav class="security-section-nav" aria-label="Security Center sections">
|
||||
<button id="security-section-activity" type="button">Activity</button>
|
||||
<button id="security-section-devices" type="button">Devices</button>
|
||||
<button id="security-section-passkeys" type="button">Passkeys</button>
|
||||
</nav>
|
||||
<section id="security-activity-section" class="security-activity" aria-labelledby="security-activity-title">
|
||||
<div class="security-activity-header">
|
||||
<h3 id="security-activity-title">Security activity</h3>
|
||||
<p class="small muted">Recent sign-ins, device changes, and protected actions.</p>
|
||||
@@ -71,6 +65,24 @@
|
||||
<div id="security-activity-list" class="security-activity-list"></div>
|
||||
<button id="load-more-security-activity" type="button" hidden>Load older activity</button>
|
||||
</section>
|
||||
<section id="security-devices-section" class="security-devices" aria-labelledby="security-devices-title">
|
||||
<div class="security-activity-header">
|
||||
<h3 id="security-devices-title">Active devices</h3>
|
||||
<p class="small muted">Review signed-in devices and remove access you no longer trust.</p>
|
||||
</div>
|
||||
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
|
||||
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
|
||||
<div id="active-devices-list" class="active-devices-list"></div>
|
||||
</section>
|
||||
<section id="security-passkeys-section" class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
|
||||
<div class="enrolled-passkeys-header">
|
||||
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
|
||||
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
|
||||
<p class="small muted">A recovery token will be required after sign-out if you remove this device’s passkey.</p>
|
||||
</div>
|
||||
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -107,11 +119,23 @@
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
<section class="device-storage" aria-labelledby="device-storage-heading">
|
||||
<div>
|
||||
<h3 id="device-storage-heading">Private device storage</h3>
|
||||
<p class="small" id="device-storage-summary" role="status" aria-live="polite">Checking private storage…</p>
|
||||
<p class="small muted" id="device-storage-detail"></p>
|
||||
</div>
|
||||
<div class="device-storage-actions">
|
||||
<button id="clear-device-caches" type="button">Clear cached copies</button>
|
||||
<button id="clear-private-device-data" type="button">Clear all private data</button>
|
||||
</div>
|
||||
</section>
|
||||
<p id="device-setup-ready-status" class="device-setup-ready" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div id="mobile-pull-refresh" class="mobile-pull-refresh" role="status" aria-live="polite" hidden></div>
|
||||
<section class="panel my-work" id="my-work" tabindex="-1">
|
||||
<div class="my-work-header">
|
||||
<div>
|
||||
@@ -273,6 +297,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="insights-shell" id="insights-sheet" aria-labelledby="insights-heading">
|
||||
<div class="insights-header">
|
||||
<div><h2 id="insights-heading">Insights</h2><p class="small muted">Context, activity, live signals, and workspace tools.</p></div>
|
||||
<button id="close-insights" type="button">Close</button>
|
||||
</div>
|
||||
<aside class="sidebar">
|
||||
<details class="panel stack" data-panel-key="context" open>
|
||||
<summary><h2>Context & view</h2></summary>
|
||||
@@ -334,6 +363,7 @@
|
||||
<div id="md-preview" style="margin-top:8px;"></div>
|
||||
</details>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="plan-today-sheet" id="plan-today-sheet" role="dialog" aria-modal="true" aria-labelledby="plan-today-title" hidden>
|
||||
@@ -406,13 +436,38 @@
|
||||
<div id="today-recap-history" class="today-recap-history"></div>
|
||||
</section>
|
||||
<div class="today-recap-actions">
|
||||
<button id="save-today-recap" type="button">Save recap & adjust plan</button>
|
||||
<button id="save-today-recap" type="button">Save recap & wrap up</button>
|
||||
<button id="save-log-today-recap" type="button">Log selected time to Gitea</button>
|
||||
<button id="discard-today-recap" type="button">Keep timer & close</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<dialog class="today-handoff-dialog" id="today-handoff-dialog" aria-labelledby="today-handoff-title">
|
||||
<section class="today-handoff-panel">
|
||||
<header><div><div class="small">Yesterday’s commitments</div><h2 id="today-handoff-title">Review tomorrow’s plan</h2></div><button id="close-today-handoff" type="button">Not now</button></header>
|
||||
<p class="small muted">Choose the work you still intend to do Today. Unchecked work stays safely in My Work.</p>
|
||||
<div id="today-handoff-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="today-handoff-items" class="today-handoff-items"></div>
|
||||
<div class="today-handoff-actions"><button id="confirm-today-handoff" type="button">Add selected to Today</button></div>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<div class="today-wrap-up-sheet" id="today-wrap-up-sheet" role="dialog" aria-modal="true" aria-labelledby="today-wrap-up-title" hidden>
|
||||
<section class="today-wrap-up-panel">
|
||||
<div class="today-wrap-up-header">
|
||||
<div><div class="small">End your day intentionally</div><h2 id="today-wrap-up-title">Wrap up Today</h2></div>
|
||||
<button id="close-today-wrap-up" type="button">Not now</button>
|
||||
</div>
|
||||
<p class="small muted">Choose unfinished work to schedule for tomorrow at 09:00. Unchecked work stays in Today.</p>
|
||||
<div id="today-wrap-up-status" class="small" role="status" aria-live="polite"></div>
|
||||
<div id="today-wrap-up-items" class="today-wrap-up-items"></div>
|
||||
<div class="today-wrap-up-actions">
|
||||
<button id="finish-today-wrap-up" type="button">Finish wrap-up</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
|
||||
<section class="today-readiness-panel">
|
||||
<div class="today-readiness-header">
|
||||
@@ -1487,6 +1542,7 @@
|
||||
<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="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
|
||||
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">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="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
|
||||
@@ -1498,6 +1554,20 @@
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<dialog id="mobile-delivery-recovery" class="mobile-delivery-recovery" aria-labelledby="mobile-delivery-recovery-title">
|
||||
<section class="mobile-delivery-recovery-panel">
|
||||
<header>
|
||||
<div><p class="small muted" id="mobile-delivery-recovery-progress"></p><h2 id="mobile-delivery-recovery-title">Recover delivery</h2></div>
|
||||
<button id="close-mobile-delivery-recovery" type="button">Close</button>
|
||||
</header>
|
||||
<p id="mobile-delivery-recovery-destination" class="small"></p>
|
||||
<p id="mobile-delivery-recovery-reason" class="delivery-recovery-reason"></p>
|
||||
<p id="mobile-delivery-recovery-status" class="small" role="status" aria-live="polite"></p>
|
||||
<button id="mobile-delivery-recovery-action" type="button">Recover delivery</button>
|
||||
<details><summary>Other recovery options</summary><p class="small muted">Close this guide to edit, copy, or discard the saved delivery from Drafts.</p></details>
|
||||
</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="find" type="button">Find</button>
|
||||
@@ -1547,6 +1617,8 @@
|
||||
<script src="static/today-work.js"></script>
|
||||
<script src="static/today-timer.js"></script>
|
||||
<script src="static/today-recap.js"></script>
|
||||
<script src="static/today-wrap-up.js"></script>
|
||||
<script src="static/today-handoff.js"></script>
|
||||
<script src="static/today-completion.js"></script>
|
||||
<script src="static/today-readiness.js"></script>
|
||||
<script src="static/comment-next.js"></script>
|
||||
@@ -1589,8 +1661,10 @@
|
||||
<script src="static/context-poller.js"></script>
|
||||
<script src="static/live-data-status.js"></script>
|
||||
<script src="static/mobile-task-dock.js"></script>
|
||||
<script src="static/mobile-pull-refresh.js"></script>
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-queue-launcher.js"></script>
|
||||
<script src="static/mobile-delivery-recovery.js"></script>
|
||||
<script src="static/mobile-start-day.js"></script>
|
||||
<script src="static/update-triage-session.js"></script>
|
||||
<script src="static/update-review-handoff.js"></script>
|
||||
@@ -1601,8 +1675,12 @@
|
||||
<script src="static/update-decision-transaction.js"></script>
|
||||
<script src="static/agenda-session-launcher.js"></script>
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/mobile-insights.js"></script>
|
||||
<script src="static/mobile-app-shortcuts.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/private-data-inventory.js"></script>
|
||||
<script src="static/private-device-data.js"></script>
|
||||
<script src="static/device-storage.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
<script src="static/mobile-search-viewport.js"></script>
|
||||
<script src="static/mobile-composer-viewport.js"></script>
|
||||
@@ -1615,6 +1693,7 @@
|
||||
<script src="static/mobile-search-preview-nav.js"></script>
|
||||
<script src="static/mobile-plan-today-nav.js"></script>
|
||||
<script src="static/mobile-find-work-nav.js"></script>
|
||||
<script src="static/workspace-bootstrap.js"></script>
|
||||
<script src="static/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -160,15 +160,17 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, wakeAt = null) {
|
||||
function enqueue(action, itemId, wakeAt = null, handoff = null) {
|
||||
if (!['defer', 'restore'].includes(action) || !itemId ||
|
||||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
|
||||
(action === 'defer' && typeof wakeAt !== 'string') ||
|
||||
![null, 'today'].includes(handoff)) return false;
|
||||
pending().filter(operation => operation.item_id === itemId)
|
||||
.forEach(operation => removeOperation(operation.operation_id));
|
||||
const operation = {
|
||||
operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt,
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
if (handoff) operation.handoff = handoff;
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
@@ -189,9 +191,11 @@ function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStat
|
||||
const marker = migrationPrefix + storageKey.slice(prefix.length);
|
||||
try {
|
||||
if (storage.getItem(marker)) return false;
|
||||
Object.entries(records || {}).forEach(([itemId, wakeAt]) =>
|
||||
enqueue('defer', itemId, wakeAt)
|
||||
);
|
||||
Object.entries(records || {}).forEach(([itemId, record]) => {
|
||||
const wakeAt = record && typeof record === 'object' ? record.wake_at : record;
|
||||
const handoff = record?.handoff === 'today' ? 'today' : null;
|
||||
enqueue('defer', itemId, wakeAt, handoff);
|
||||
});
|
||||
storage.setItem(marker, '1');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
|
||||
+22
-10
@@ -38,7 +38,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
}
|
||||
}
|
||||
|
||||
function defer(item, until) {
|
||||
function defer(item, until, options = {}) {
|
||||
const key = storageKey();
|
||||
const id = identity(item);
|
||||
const wake = new Date(until);
|
||||
@@ -46,9 +46,10 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
|
||||
const records = read();
|
||||
const wakeAt = wake.toISOString();
|
||||
records[id] = wakeAt;
|
||||
const handoff = options.handoff === 'today' ? 'today' : null;
|
||||
records[id] = handoff ? { wake_at:wakeAt, handoff } : wakeAt;
|
||||
if (!write(records)) return 'unavailable';
|
||||
onChange('defer', id, wakeAt);
|
||||
onChange('defer', id, wakeAt, handoff);
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
@@ -93,9 +94,13 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
function adopt(records) {
|
||||
if (!records || typeof records !== 'object' || Array.isArray(records)) return false;
|
||||
const normalized = {};
|
||||
Object.entries(records).forEach(([id, wake]) => {
|
||||
Object.entries(records).forEach(([id, record]) => {
|
||||
const wake = record && typeof record === 'object' ? record.wake_at : record;
|
||||
const wakeTime = new Date(wake).getTime();
|
||||
if (id && Number.isFinite(wakeTime)) normalized[id] = new Date(wakeTime).toISOString();
|
||||
if (id && Number.isFinite(wakeTime)) {
|
||||
const wakeAt = new Date(wakeTime).toISOString();
|
||||
normalized[id] = record?.handoff === 'today' ? { wake_at:wakeAt, handoff:'today' } : wakeAt;
|
||||
}
|
||||
});
|
||||
return write(normalized);
|
||||
}
|
||||
@@ -119,25 +124,32 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||
const wakeTimes = [];
|
||||
const expired = [];
|
||||
|
||||
Object.entries(records).forEach(([id, wake]) => {
|
||||
const handoffs = [];
|
||||
Object.entries(records).forEach(([id, record]) => {
|
||||
const wake = record && typeof record === 'object' ? record.wake_at : record;
|
||||
const wakeTime = new Date(wake).getTime();
|
||||
if (!Number.isFinite(wakeTime) || wakeTime <= current) {
|
||||
if (Number.isFinite(wakeTime)) expired.push(id);
|
||||
if (Number.isFinite(wakeTime)) {
|
||||
expired.push(id);
|
||||
if (record?.handoff === 'today') handoffs.push(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pruneMissing && !available.has(id)) return;
|
||||
retained[id] = new Date(wakeTime).toISOString();
|
||||
const wakeAt = new Date(wakeTime).toISOString();
|
||||
retained[id] = record?.handoff === 'today' ? { wake_at:wakeAt, handoff:'today' } : wakeAt;
|
||||
wakeTimes.push(wakeTime);
|
||||
});
|
||||
|
||||
if (JSON.stringify(retained) !== JSON.stringify(records) && write(retained) && expired.length) {
|
||||
onExpire(expired);
|
||||
onExpire(expired, handoffs);
|
||||
}
|
||||
schedule(wakeTimes, current);
|
||||
const deferredIds = new Set(Object.keys(retained));
|
||||
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
|
||||
const later = (items || []).flatMap(item => {
|
||||
const wake = retained[identity(item)];
|
||||
const record = retained[identity(item)];
|
||||
const wake = record && typeof record === 'object' ? record.wake_at : record;
|
||||
return wake ? [{ ...item, deferred_until: wake }] : [];
|
||||
});
|
||||
return { active, later };
|
||||
|
||||
+4
-2
@@ -4,7 +4,7 @@
|
||||
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
|
||||
function validShareContinuation(value) {
|
||||
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
|
||||
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5, search: 200,
|
||||
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 6, search: 200,
|
||||
preview: 200, search_kind: 5, search_state: 6, search_repository: 161 };
|
||||
const params = new URLSearchParams(value.slice(3));
|
||||
const entries = Array.from(params.entries());
|
||||
@@ -35,7 +35,9 @@
|
||||
const launch = params.get('launch');
|
||||
const shared = params.get('shared');
|
||||
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
|
||||
if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
|
||||
if (shared === 'bundle') {
|
||||
if (launch !== 'new' || entries.some(([name]) => !['launch', 'shared'].includes(name))) return './';
|
||||
} else if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileDeliveryRecovery = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileDeliveryRecovery(options) {
|
||||
const byId = id => typeof document === 'undefined' ? null : document.getElementById(id);
|
||||
const elements = options.elements || (typeof document === 'undefined' ? null : {
|
||||
dialog: byId('mobile-delivery-recovery'), close: byId('close-mobile-delivery-recovery'),
|
||||
title: byId('mobile-delivery-recovery-title'), destination: byId('mobile-delivery-recovery-destination'),
|
||||
reason: byId('mobile-delivery-recovery-reason'), progress: byId('mobile-delivery-recovery-progress'),
|
||||
action: byId('mobile-delivery-recovery-action'), status: byId('mobile-delivery-recovery-status'),
|
||||
});
|
||||
const priority = {authorization: 0, attention: 1, uncertain: 2, waiting: 3};
|
||||
|
||||
function identity(item) {
|
||||
return String(item?.outbox_id || item?.id || '');
|
||||
}
|
||||
|
||||
function state(item) {
|
||||
if (item?.status === 'authorization') return 'authorization';
|
||||
if (item?.status === 'attention' || item?.checklist_conflict || item?.quarantined) return 'attention';
|
||||
if (item?.delivery_state === 'uncertain') return 'uncertain';
|
||||
return 'waiting';
|
||||
}
|
||||
|
||||
function primary(item) {
|
||||
const kind = state(item);
|
||||
if (kind === 'authorization') {
|
||||
return item?.outbox_kind === 'issue-close' ? 'Authorize & close' : 'Authorize & send';
|
||||
}
|
||||
if (item?.checklist_conflict) return 'Review changes';
|
||||
if (kind === 'attention') return item?.quarantined ? 'Copy content' : 'Open current item';
|
||||
if (kind === 'uncertain') return 'Verified not posted — retry';
|
||||
return 'Retry now';
|
||||
}
|
||||
|
||||
function ordered() {
|
||||
return [...(options.getItems ? options.getItems() : [])].sort((left, right) => {
|
||||
const byState = priority[state(left)] - priority[state(right)];
|
||||
if (byState) return byState;
|
||||
return identity(left).localeCompare(identity(right));
|
||||
});
|
||||
}
|
||||
|
||||
const stateLabels = {
|
||||
authorization: 'Authorization required',
|
||||
attention: 'Needs attention',
|
||||
uncertain: 'Delivery uncertain',
|
||||
waiting: 'Ready to retry',
|
||||
};
|
||||
let currentId = '';
|
||||
let active = false;
|
||||
let inFlight = null;
|
||||
|
||||
function currentItem(items) {
|
||||
return items.find(item => identity(item) === currentId) || items[0];
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const items = ordered();
|
||||
const item = currentItem(items);
|
||||
if (item) currentId = identity(item);
|
||||
else currentId = '';
|
||||
const index = item ? items.indexOf(item) : -1;
|
||||
return {
|
||||
count: items.length,
|
||||
current: item ? {
|
||||
id: identity(item),
|
||||
state: state(item),
|
||||
primary: primary(item),
|
||||
position: index + 1,
|
||||
total: items.length,
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
const result = snapshot();
|
||||
if (!elements) return result;
|
||||
const items = ordered();
|
||||
const item = currentItem(items);
|
||||
if (!item) {
|
||||
if (active) {
|
||||
active = false;
|
||||
if (elements.dialog?.open) elements.dialog.close();
|
||||
if (options.onComplete) options.onComplete();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
elements.title.textContent = item.title || 'Queued delivery';
|
||||
elements.destination.textContent = [item.repository, item.details].filter(Boolean).join(' · ') || 'Saved delivery';
|
||||
elements.reason.textContent = item.last_attempt_error || item.ownership || stateLabels[state(item)];
|
||||
elements.progress.textContent = 'Delivery ' + result.current.position + ' of ' + result.current.total + ' · ' + stateLabels[state(item)];
|
||||
elements.action.textContent = primary(item);
|
||||
if (elements.status) elements.status.textContent = '';
|
||||
return result;
|
||||
}
|
||||
|
||||
function open() {
|
||||
const queue = byId('mobile-queue-sheet');
|
||||
if (queue?.open) queue.close();
|
||||
if (options.beforeOpen) options.beforeOpen();
|
||||
currentId = '';
|
||||
const result = snapshot();
|
||||
if (!result.current) return 'empty';
|
||||
active = true;
|
||||
render();
|
||||
if (elements?.dialog && !elements.dialog.open) elements.dialog.showModal();
|
||||
return 'opened';
|
||||
}
|
||||
|
||||
async function attend(item) {
|
||||
const index = options.getIndex ? options.getIndex(item) : -1;
|
||||
const selector = item.quarantined ? '.draft-copy' :
|
||||
item.checklist_conflict ? '.draft-review-checklist' : '.draft-resume';
|
||||
const action = document.querySelector('#my-work-list ' + selector + '[data-draft-index="' + index + '"]');
|
||||
if (!action) throw new Error('Delivery changed.');
|
||||
elements.dialog.close();
|
||||
action.scrollIntoView();
|
||||
action.focus();
|
||||
return await action.onclick();
|
||||
}
|
||||
|
||||
function activate() {
|
||||
if (inFlight) return inFlight;
|
||||
const items = ordered();
|
||||
const item = currentItem(items);
|
||||
const perform = state(item) === 'attention' ? attend : options.activate;
|
||||
if (!item) return Promise.resolve(render());
|
||||
if (elements?.action) elements.action.disabled = true;
|
||||
if (elements?.status) elements.status.textContent = 'Working…';
|
||||
inFlight = (async () => {
|
||||
let failure = null;
|
||||
try {
|
||||
await perform(item, state(item));
|
||||
} catch (error) {
|
||||
failure = String(error?.message || 'Delivery could not be completed.').trim().slice(0, 160);
|
||||
} finally {
|
||||
if (elements?.action) elements.action.disabled = false;
|
||||
}
|
||||
const result = render();
|
||||
if (failure && elements?.status) {
|
||||
if (active && !elements.dialog.open) elements.dialog.showModal();
|
||||
elements.status.textContent = failure + ' Try again.';
|
||||
}
|
||||
return result;
|
||||
})().finally(() => { inFlight = null; });
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (elements?.action?.addEventListener) elements.action.addEventListener('click', activate);
|
||||
if (elements?.close?.addEventListener) elements.close.addEventListener('click', () => elements.dialog.close());
|
||||
}
|
||||
|
||||
return {activate, open, render, snapshot, start};
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileInsights = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function (o) {
|
||||
const route = '#/insights';
|
||||
let active = false;
|
||||
let previous = '#/my-work';
|
||||
|
||||
function attr(element, name, value) {
|
||||
if (value === null) element.removeAttribute(name);
|
||||
else element.setAttribute(name, value);
|
||||
}
|
||||
|
||||
function render(open, focus = false) {
|
||||
active = Boolean(open && o.mediaQuery.matches);
|
||||
attr(o.root, 'data-mobile-open', active ? 'true' : null);
|
||||
attr(o.root, 'role', active ? 'dialog' : null);
|
||||
attr(o.root, 'aria-modal', active ? 'true' : null);
|
||||
attr(o.root, 'aria-hidden', active || !o.mediaQuery.matches ? null : 'true');
|
||||
o.root.inert = !active && o.mediaQuery.matches;
|
||||
o.backgrounds.forEach(element => { element.inert = active; });
|
||||
o.dock.hidden = active;
|
||||
attr(o.hud, 'data-overlay-hidden', active ? 'true' : null);
|
||||
if (active) o.closeButton.focus();
|
||||
else if (focus) o.launcher.focus();
|
||||
}
|
||||
|
||||
function url(hash) {
|
||||
return (o.location.pathname || '') + (o.location.search || '') + hash;
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!o.mediaQuery.matches || active) return false;
|
||||
previous = o.location.hash && o.location.hash !== route ? o.location.hash : '#/my-work';
|
||||
if (o.menu) o.menu.open = false;
|
||||
o.history.pushState({ ...(o.history.state || {}), mobileInsights:true, previousHash:previous }, '', url(route));
|
||||
render(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sync() {
|
||||
render(o.location.hash === route, active && o.location.hash !== route);
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!active) return false;
|
||||
if (o.history.state?.mobileInsights) o.history.back();
|
||||
else {
|
||||
const state = { ...(o.history.state || {}) };
|
||||
delete state.mobileInsights;
|
||||
delete state.previousHash;
|
||||
o.history.replaceState(state, '', url(previous));
|
||||
render(false, true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function start() {
|
||||
o.launcher.addEventListener('click', open);
|
||||
o.closeButton.addEventListener('click', close);
|
||||
o.eventTarget.addEventListener('popstate', sync);
|
||||
o.eventTarget.addEventListener('hashchange', sync);
|
||||
o.eventTarget.addEventListener('keydown', event => {
|
||||
if (active && event.key === 'Escape') {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
}
|
||||
});
|
||||
o.mediaQuery.addEventListener?.('change', sync);
|
||||
sync();
|
||||
}
|
||||
|
||||
return { start, open, close, current:() => active };
|
||||
});
|
||||
@@ -14,7 +14,7 @@ function createMobileIssueDetailNavigation(options) {
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(name) {
|
||||
function navigate(name, navigationOptions = {}) {
|
||||
const target = targets[name];
|
||||
if (!target) return false;
|
||||
const prepare = options.beforeNavigate && options.beforeNavigate[name];
|
||||
@@ -24,7 +24,7 @@ function createMobileIssueDetailNavigation(options) {
|
||||
block: 'start',
|
||||
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
if (name === 'reply') target.focus();
|
||||
if (name === 'reply' && navigationOptions.focus !== false) target.focus();
|
||||
select(name);
|
||||
return true;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ function createMobileIssueDetailNavigation(options) {
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
@@ -55,7 +56,11 @@ function createMobileIssueDetailNavigation(options) {
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) select(targetNames.get(visible.target));
|
||||
if (visible) {
|
||||
const name = targetNames.get(visible.target);
|
||||
select(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
|
||||
}
|
||||
}, Array.from(targetNames.keys()).filter(Boolean));
|
||||
},
|
||||
stop() {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.createMobilePullRefresh = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobilePullRefresh(options) {
|
||||
const surface = options.surface || document.querySelector('#my-work');
|
||||
const indicator = options.indicator || document.querySelector('#mobile-pull-refresh');
|
||||
const threshold = options.threshold || 64;
|
||||
const isMobile = options.isMobile || (() => window.matchMedia('(max-width: 600px)').matches);
|
||||
const getScrollY = options.getScrollY || (() => window.scrollY);
|
||||
const isBlocked = options.isBlocked || (() => Array.from(options.overlays || document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal')).some(overlay =>
|
||||
(overlay.checkVisibility ? overlay.checkVisibility() : !overlay.closest('[hidden]')) &&
|
||||
(overlay.classList.contains('open') || overlay.getAttribute('aria-modal') === 'true')));
|
||||
let gesture = null;
|
||||
let inFlight = null;
|
||||
|
||||
function show(state, text) {
|
||||
indicator.hidden = false;
|
||||
indicator.dataset.state = state;
|
||||
indicator.textContent = text;
|
||||
}
|
||||
|
||||
function resetGesture() {
|
||||
gesture = null;
|
||||
}
|
||||
|
||||
function startsOnExcludedTarget(target) {
|
||||
if (!target) return false;
|
||||
if (target.closest?.('button, a, input, textarea, select, summary, [contenteditable="true"], [role="button"]')) return true;
|
||||
for (let node = target; node && node !== surface; node = node.parentElement) {
|
||||
if ((node.scrollWidth || 0) > (node.clientWidth || 0)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (inFlight || event.pointerType !== 'touch' || !isMobile() || getScrollY() > 0 || isBlocked() || startsOnExcludedTarget(event.target)) return;
|
||||
gesture = {id:event.pointerId, x:event.clientX, y:event.clientY, ready:false};
|
||||
surface.setPointerCapture?.(event.pointerId);
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (!gesture || gesture.id !== event.pointerId) return;
|
||||
const dx = event.clientX - gesture.x;
|
||||
const dy = event.clientY - gesture.y;
|
||||
if (dy <= 0 || Math.abs(dx) > dy) return resetGesture();
|
||||
event.preventDefault();
|
||||
gesture.ready = dy >= threshold;
|
||||
show(gesture.ready ? 'ready' : 'pulling', gesture.ready ? 'Release to refresh' : 'Pull to refresh');
|
||||
}
|
||||
|
||||
async function onPointerUp(event) {
|
||||
if (!gesture || gesture.id !== event.pointerId) return;
|
||||
const shouldRefresh = gesture.ready;
|
||||
resetGesture();
|
||||
surface.releasePointerCapture?.(event.pointerId);
|
||||
if (!shouldRefresh || inFlight) return;
|
||||
show('refreshing', 'Refreshing My Work…');
|
||||
inFlight = Promise.resolve().then(options.refresh);
|
||||
try {
|
||||
await inFlight;
|
||||
show('success', 'My Work is up to date');
|
||||
} catch (_) {
|
||||
show('error', 'Could not refresh · pull to retry');
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerCancel() {
|
||||
resetGesture();
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
surface.addEventListener('pointerdown', onPointerDown);
|
||||
surface.addEventListener('pointermove', onPointerMove);
|
||||
surface.addEventListener('pointerup', onPointerUp);
|
||||
surface.addEventListener('pointercancel', onPointerCancel);
|
||||
},
|
||||
stop() {
|
||||
surface.removeEventListener('pointerdown', onPointerDown);
|
||||
surface.removeEventListener('pointermove', onPointerMove);
|
||||
surface.removeEventListener('pointerup', onPointerUp);
|
||||
surface.removeEventListener('pointercancel', onPointerCancel);
|
||||
resetGesture();
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -9,10 +9,12 @@
|
||||
draft: 'No drafts are ready to open.',
|
||||
};
|
||||
const continuation = [
|
||||
['delivery', 'Recover Delivery'],
|
||||
['attention', 'Start Attention'],
|
||||
['today', 'Continue Today'],
|
||||
['update', 'Resume Updates'],
|
||||
['agenda', 'Open Agenda'],
|
||||
['filed', 'Review Filed'],
|
||||
['later', 'Start Later'],
|
||||
['draft', 'Open Drafts'],
|
||||
];
|
||||
@@ -27,6 +29,7 @@
|
||||
}
|
||||
|
||||
function open(name) {
|
||||
if (name === 'delivery' && options.openDelivery) return options.openDelivery();
|
||||
if (name === 'today') return options.openToday();
|
||||
if (name === 'agenda') return options.openAgenda();
|
||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||
|
||||
@@ -14,14 +14,14 @@ function createMobileReviewDetailNavigation(options) {
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(name) {
|
||||
function navigate(name, navigationOptions = {}) {
|
||||
const target = targets[name];
|
||||
if (!target) return false;
|
||||
target.scrollIntoView({
|
||||
block:'start',
|
||||
behavior:prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
if (name === 'feedback' && options.summaryComposer) {
|
||||
if (name === 'feedback' && options.summaryComposer && navigationOptions.focus !== false) {
|
||||
options.summaryComposer.focus({ preventScroll:true });
|
||||
}
|
||||
select(name);
|
||||
@@ -39,6 +39,7 @@ function createMobileReviewDetailNavigation(options) {
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
@@ -58,7 +59,11 @@ function createMobileReviewDetailNavigation(options) {
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) select(targetNames.get(visible.target));
|
||||
if (visible) {
|
||||
const name = targetNames.get(visible.target);
|
||||
select(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
|
||||
}
|
||||
}, Array.from(targetNames.keys()).filter(Boolean), options.root || null);
|
||||
},
|
||||
stop() {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
const checkpointKey = 'stackchain.mobile-start-day.v1';
|
||||
const storage = options.storage || (typeof localStorage !== 'undefined' ? localStorage : null);
|
||||
const reviewOrder = [
|
||||
['delivery', 'Delivery recovery'],
|
||||
['agenda', 'Agenda'],
|
||||
['attention', 'Attention'],
|
||||
['update', 'Updates'],
|
||||
@@ -73,13 +74,19 @@
|
||||
.filter(phase => phase.count > 0);
|
||||
const total = phases.reduce((sum, phase) => sum + phase.count, 0);
|
||||
const today = count(counts.today);
|
||||
const delivery = count(counts.delivery);
|
||||
const other = total - delivery;
|
||||
const next = phases.length ? phases[0].name : (today ? 'today' : 'find');
|
||||
const nextLabel = phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work');
|
||||
const nextLabel = next === 'delivery' ? 'Review Delivery' :
|
||||
(phases.length ? 'Review ' + phases[0].label : (today ? 'Continue Today' : 'Find Work'));
|
||||
return {
|
||||
total,
|
||||
next,
|
||||
label: nextLabel,
|
||||
summary: total ? total + ' items before Today · ' + today + ' planned' :
|
||||
summary: delivery ? delivery + (delivery === 1 ? ' delivery needs' : ' deliveries need') +
|
||||
' action before Today' + (other ? ' · ' + other + ' other ' + (other === 1 ? 'item' : 'items') : '') +
|
||||
' · ' + today + ' planned' :
|
||||
total ? total + ' items before Today · ' + today + ' planned' :
|
||||
(today ? 'Review clear · ' + today + ' planned' : 'Review clear · Today is empty'),
|
||||
phases,
|
||||
};
|
||||
@@ -107,9 +114,9 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
function reconcile({authoritative = false} = {}) {
|
||||
function reconcile({authoritative = false, authoritativePhases = []} = {}) {
|
||||
const saved = checkpoint();
|
||||
if (!authoritative || !saved?.phase) return false;
|
||||
if (!saved?.phase || (!authoritative && !authoritativePhases.includes(saved.phase))) return false;
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
if (count(counts[saved.phase]) > 0) return false;
|
||||
return completePhase(saved.phase);
|
||||
|
||||
@@ -66,9 +66,9 @@
|
||||
function updateWork(mode) {
|
||||
const text = {
|
||||
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
|
||||
attention:'Attention', update:'Updates', agenda:'Agenda', later:'Later', draft:'Drafts',
|
||||
delivery:'Delivery', attention:'Attention', update:'Updates', agenda:'Agenda', filed:'Filed', later:'Later', draft:'Drafts',
|
||||
}[mode] || 'Work';
|
||||
const queue = ['Attention', 'Updates', 'Agenda', 'Later', 'Drafts'].includes(text);
|
||||
const queue = ['Delivery', 'Attention', 'Updates', 'Agenda', 'Filed', 'Later', 'Drafts'].includes(text);
|
||||
if (options.workLabel) options.workLabel.textContent = text;
|
||||
if (buttons.work) buttons.work.setAttribute('aria-label', queue
|
||||
? (mode === 'update' ? 'Resume ' : 'Open ') + text
|
||||
@@ -91,9 +91,9 @@
|
||||
}
|
||||
|
||||
function updateQueues(counts) {
|
||||
const names = 'today agenda attention update filed later draft'.split(' ');
|
||||
const names = 'today agenda delivery attention update filed later draft'.split(' ');
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const actionableNames = ['today', 'attention', 'update', 'filed', 'later', 'draft'];
|
||||
const actionableNames = ['today', 'delivery', 'attention', 'update', 'filed', 'later', 'draft'];
|
||||
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
@@ -117,6 +117,7 @@
|
||||
? 'Queues: no active queues; no upcoming deadlines'
|
||||
: 'Queues: Today ' + normalized.today
|
||||
+ ', Agenda ' + normalized.agenda + ' due'
|
||||
+ ', Delivery ' + normalized.delivery
|
||||
+ ', Attention ' + normalized.attention
|
||||
+ ', Updates ' + normalized.update
|
||||
+ ', Filed ' + normalized.filed
|
||||
|
||||
@@ -14,7 +14,7 @@ function createMobileUpdateDetailNavigation(options) {
|
||||
});
|
||||
}
|
||||
|
||||
function navigate(name) {
|
||||
function navigate(name, navigationOptions = {}) {
|
||||
if (name === 'activity') {
|
||||
if (options.jumpToNewActivity) options.jumpToNewActivity();
|
||||
select(name);
|
||||
@@ -27,7 +27,7 @@ function createMobileUpdateDetailNavigation(options) {
|
||||
block:'start',
|
||||
behavior:prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
});
|
||||
if (name === 'reply' && options.replyComposer) {
|
||||
if (name === 'reply' && options.replyComposer && navigationOptions.focus !== false) {
|
||||
options.replyComposer.focus({ preventScroll:true });
|
||||
}
|
||||
select(name);
|
||||
@@ -46,6 +46,7 @@ function createMobileUpdateDetailNavigation(options) {
|
||||
const listener = event => {
|
||||
event.preventDefault();
|
||||
navigate(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:false });
|
||||
};
|
||||
listeners.set(button, listener);
|
||||
button.addEventListener('click', listener);
|
||||
@@ -65,7 +66,11 @@ function createMobileUpdateDetailNavigation(options) {
|
||||
const visible = entries
|
||||
.filter(entry => entry.isIntersecting && targetNames.has(entry.target))
|
||||
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
|
||||
if (visible) select(targetNames.get(visible.target));
|
||||
if (visible) {
|
||||
const name = targetNames.get(visible.target);
|
||||
select(name);
|
||||
if (options.onSectionChange) options.onSectionChange(name, { replace:true });
|
||||
}
|
||||
}, Array.from(targetNames.keys()).filter(Boolean));
|
||||
},
|
||||
stop() {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
function mode() {
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
const recommended = options.queueLauncher && options.queueLauncher.recommend();
|
||||
if (recommended && recommended.name !== 'today') return recommended.name;
|
||||
if (recommended && !['today', 'find'].includes(recommended.name)) return recommended.name;
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
if (options.getEligibleCount() > 0) return 'plan';
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.inspectStackchainPrivateDatabases = factory(root.indexedDB);
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function createPrivateDataInspector(indexedDB) {
|
||||
function requestResult(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('Private storage inventory failed.'));
|
||||
request.onblocked = () => reject(new Error('Private storage inventory was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
async function countDatabase(name) {
|
||||
const database = await requestResult(indexedDB.open(name));
|
||||
try {
|
||||
const storeNames = Array.from(database.objectStoreNames);
|
||||
if (!storeNames.length) return 0;
|
||||
const transaction = database.transaction(storeNames, 'readonly');
|
||||
const counts = await Promise.all(storeNames.map(storeName =>
|
||||
requestResult(transaction.objectStore(storeName).count())
|
||||
));
|
||||
return counts.reduce((total, count) => total + Number(count || 0), 0);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
return async function inspectPrivateDatabases(registeredNames) {
|
||||
if (!indexedDB?.databases) return { recordCount: 0, unavailable: true };
|
||||
try {
|
||||
const existing = new Set((await indexedDB.databases()).map(database => database.name));
|
||||
const counts = await Promise.all(
|
||||
registeredNames.filter(name => existing.has(name)).map(countDatabase)
|
||||
);
|
||||
return {
|
||||
recordCount: counts.reduce((total, count) => total + count, 0),
|
||||
unavailable: false,
|
||||
};
|
||||
} catch (_error) {
|
||||
return { recordCount: 0, unavailable: true };
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -55,9 +55,9 @@
|
||||
}
|
||||
|
||||
return async function clearPrivateDeviceData() {
|
||||
await stopWorkerOutbox();
|
||||
removeOwnedStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
|
||||
await stopWorkerOutbox();
|
||||
for (const name of privateDatabases) await deletePrivateDatabase(name);
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(
|
||||
|
||||
@@ -5,7 +5,10 @@ function createReconnectOutboxes({
|
||||
flushAuthored,
|
||||
flushNotificationReads,
|
||||
}) {
|
||||
return async function reconnectOutboxes() {
|
||||
let activeReconnect = null;
|
||||
let followUpRequested = false;
|
||||
|
||||
async function reconnectOnce() {
|
||||
const snapshot = await refresh();
|
||||
const freshness = snapshot?.freshness?.sections?.context;
|
||||
const identityFresh = snapshot?.context && !snapshot.context.error &&
|
||||
@@ -19,6 +22,22 @@ function createReconnectOutboxes({
|
||||
flushNotificationReads(),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
return function reconnectOutboxes() {
|
||||
if (activeReconnect) {
|
||||
followUpRequested = true;
|
||||
return activeReconnect;
|
||||
}
|
||||
activeReconnect = (async () => {
|
||||
let result = false;
|
||||
do {
|
||||
followUpRequested = false;
|
||||
result = await reconnectOnce();
|
||||
} while (followUpRequested);
|
||||
return result;
|
||||
})().finally(() => { activeReconnect = null; });
|
||||
return activeReconnect;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,49 @@
|
||||
const activityList = root.document.getElementById('security-activity-list');
|
||||
const activityStatus = root.document.getElementById('security-activity-status');
|
||||
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
|
||||
const sectionButtons = Object.fromEntries(['activity', 'devices', 'passkeys'].map(section => [
|
||||
section, root.document.getElementById(`security-section-${section}`),
|
||||
]));
|
||||
const sections = Object.fromEntries(['activity', 'devices', 'passkeys'].map(section => [
|
||||
section, root.document.getElementById(`security-${section}-section`),
|
||||
]));
|
||||
let activityCursor = null;
|
||||
|
||||
const navigate = (section, { history = true } = {}) => {
|
||||
if (!sections[section]) section = 'activity';
|
||||
Object.entries(sectionButtons).forEach(([name, button]) => {
|
||||
if (!button) return;
|
||||
if (name === section) button.setAttribute('aria-current', 'page');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
sections[section]?.scrollIntoView?.({ block: 'start' });
|
||||
if (history && root.history?.pushState) {
|
||||
root.history.pushState(
|
||||
{ ...(root.history.state || {}), stackchainSecuritySection: section },
|
||||
'',
|
||||
`${root.location.pathname}${root.location.search || ''}#security/${section}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Object.entries(sectionButtons).forEach(([section, button]) => {
|
||||
button?.addEventListener('click', () => navigate(section));
|
||||
});
|
||||
root.document.getElementById('close-active-devices')?.addEventListener('click', () => {
|
||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||
});
|
||||
root.addEventListener?.('popstate', event => {
|
||||
const section = event.state?.stackchainSecuritySection;
|
||||
if (section && !devicesSheet.hidden) {
|
||||
navigate(section, { history: false });
|
||||
return;
|
||||
}
|
||||
if (!devicesSheet.hidden) {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const renderDevices = async () => {
|
||||
devicesStatus.textContent = 'Loading active devices…';
|
||||
devicesList.replaceChildren();
|
||||
@@ -40,7 +81,9 @@
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
|
||||
else if (await boundary.revokeActiveDevice(device)) {
|
||||
await Promise.all([renderDevices(), renderSecurityActivity()]);
|
||||
}
|
||||
});
|
||||
row.append(details, revoke);
|
||||
devicesList.append(row);
|
||||
@@ -82,6 +125,7 @@
|
||||
: 'Passkey removed.';
|
||||
await renderPasskeys();
|
||||
if (outcome.session_revoked) await renderDevices();
|
||||
await renderSecurityActivity();
|
||||
} else {
|
||||
remove.disabled = false;
|
||||
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
||||
@@ -122,7 +166,11 @@
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
details.textContent = formatted.detail;
|
||||
row.append(title, details);
|
||||
const action = root.document.createElement('button');
|
||||
action.type = 'button';
|
||||
action.textContent = 'Review devices';
|
||||
action.addEventListener('click', () => navigate('devices'));
|
||||
row.append(title, details, action);
|
||||
activityList.append(row);
|
||||
});
|
||||
}
|
||||
@@ -150,6 +198,13 @@
|
||||
.filter(value => typeof value === 'string' && value).join(' · ');
|
||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||
row.append(title, details);
|
||||
if (event.kind === 'passkey_counter_anomaly') {
|
||||
const action = root.document.createElement('button');
|
||||
action.type = 'button';
|
||||
action.textContent = 'Review passkeys';
|
||||
action.addEventListener('click', () => navigate('passkeys'));
|
||||
row.append(action);
|
||||
}
|
||||
activityList.append(row);
|
||||
});
|
||||
activityCursor = page.next_cursor;
|
||||
@@ -169,6 +224,7 @@
|
||||
if (!devicesSheet) return;
|
||||
devicesSheet.hidden = false;
|
||||
root.document.getElementById('close-active-devices')?.focus();
|
||||
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });
|
||||
return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]);
|
||||
};
|
||||
devicesButton?.addEventListener('click', open);
|
||||
@@ -186,5 +242,5 @@
|
||||
enrollPasskey.disabled = false;
|
||||
}
|
||||
});
|
||||
return { open, renderDevices, renderPasskeys, renderSecurityActivity };
|
||||
return { open, navigate, renderDevices, renderPasskeys, renderSecurityActivity };
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/private-data-registry.js');
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v114';
|
||||
const CACHE = 'stackchain-dashboard-shell-v117';
|
||||
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;
|
||||
@@ -16,6 +16,7 @@ const SHELL = [
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/feature-loader.js',
|
||||
BASE + 'static/workspace-bootstrap.js',
|
||||
BASE + 'static/conversation-action-hydrator.js',
|
||||
BASE + 'static/security-center.js',
|
||||
BASE + 'static/markdown.js',
|
||||
@@ -51,6 +52,8 @@ const SHELL = [
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
BASE + 'static/today-recap.js',
|
||||
BASE + 'static/today-wrap-up.js',
|
||||
BASE + 'static/today-handoff.js',
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
BASE + 'static/comment-next.js',
|
||||
@@ -83,6 +86,7 @@ const SHELL = [
|
||||
BASE + 'static/mobile-search-preview-nav.js',
|
||||
BASE + 'static/mobile-plan-today-nav.js',
|
||||
BASE + 'static/mobile-find-work-nav.js',
|
||||
BASE + 'static/mobile-pull-refresh.js',
|
||||
BASE + 'static/checklist-conflict.js',
|
||||
BASE + 'static/voice-transcript-store.js',
|
||||
BASE + 'static/voice-issue-capture.js',
|
||||
@@ -102,6 +106,7 @@ const SHELL = [
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/mobile-delivery-recovery.js',
|
||||
BASE + 'static/mobile-start-day.js',
|
||||
BASE + 'static/update-triage-session.js',
|
||||
BASE + 'static/update-review-handoff.js',
|
||||
@@ -112,8 +117,12 @@ const SHELL = [
|
||||
BASE + 'static/update-decision-transaction.js',
|
||||
BASE + 'static/agenda-session-launcher.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/mobile-insights.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/private-data-inventory.js',
|
||||
BASE + 'static/private-device-data.js',
|
||||
BASE + 'static/device-storage.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
@@ -379,6 +388,17 @@ async function warmOptionalFeature(cache, staleCacheNames, asset) {
|
||||
await cache.add(asset);
|
||||
}
|
||||
|
||||
async function cachedOptionalFeature(request) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE);
|
||||
await cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
@@ -631,6 +651,6 @@ self.addEventListener('fetch', event => {
|
||||
return;
|
||||
}
|
||||
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
event.respondWith(cachedOptionalFeature(request));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createTodayHandoff = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createTodayHandoff(options) {
|
||||
const prefix = 'stackchain.today-handoff.v1.';
|
||||
const storage = options.storage || null;
|
||||
const selected = new Set();
|
||||
const excluded = new Set();
|
||||
|
||||
function key() {
|
||||
const login = String(options.getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function pending() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return [];
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
return Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(ids) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const unique = [...new Set(ids.filter(id => typeof id === 'string' && id))];
|
||||
try {
|
||||
if (unique.length) storage.setItem(storageKey, JSON.stringify(unique));
|
||||
else storage.removeItem(storageKey);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function capture(ids) {
|
||||
const merged = [...pending()];
|
||||
(ids || []).forEach(id => {
|
||||
if (typeof id === 'string' && id && !merged.includes(id)) merged.push(id);
|
||||
if (typeof id === 'string' && id) {
|
||||
selected.add(id);
|
||||
excluded.delete(id);
|
||||
}
|
||||
});
|
||||
return write(merged);
|
||||
}
|
||||
|
||||
function review(items) {
|
||||
const byIdentity = new Map((items || options.items?.() || []).map(item => [options.identity(item), item]));
|
||||
return pending().flatMap(identity => {
|
||||
const item = byIdentity.get(identity);
|
||||
if (!excluded.has(identity)) selected.add(identity);
|
||||
return item ? [{ identity, item, selected:selected.has(identity) }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function choose(identity, include) {
|
||||
if (!pending().includes(identity)) return false;
|
||||
if (include) {
|
||||
selected.add(identity);
|
||||
excluded.delete(identity);
|
||||
} else {
|
||||
selected.delete(identity);
|
||||
excluded.add(identity);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function render(items, list, escapeHtml) {
|
||||
const rows = review(items);
|
||||
list.innerHTML = rows.map(row => {
|
||||
const title = String(row.item.title || row.identity).slice(0, 180);
|
||||
const context = String(row.item.key || row.item.repository || 'Work item').slice(0, 180);
|
||||
return '<div class="today-handoff-item"><span><strong>' + escapeHtml(title) + '</strong>' +
|
||||
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
|
||||
'data-today-handoff="' + escapeHtml(row.identity) + '"' + (row.selected ? ' checked' : '') +
|
||||
'> Today</label></div>';
|
||||
}).join('');
|
||||
list.querySelectorAll('[data-today-handoff]').forEach(input => input.addEventListener('change', () =>
|
||||
choose(input.dataset.todayHandoff, input.checked)));
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function commit(items) {
|
||||
const rows = review(items);
|
||||
const remaining = pending();
|
||||
let scheduled = 0;
|
||||
let blocked = 0;
|
||||
for (const row of rows) {
|
||||
if (!selected.has(row.identity)) continue;
|
||||
if (options.todayWork.contains(row.item)) {
|
||||
const index = remaining.indexOf(row.identity);
|
||||
if (index >= 0) remaining.splice(index, 1);
|
||||
selected.delete(row.identity);
|
||||
continue;
|
||||
}
|
||||
const added = options.todayWork.add(row.item);
|
||||
if (added !== 'added') {
|
||||
blocked += 1;
|
||||
continue;
|
||||
}
|
||||
if (!options.todaySync.enqueue('add', row.identity)) {
|
||||
options.todayWork.remove?.(row.item);
|
||||
blocked += 1;
|
||||
continue;
|
||||
}
|
||||
const index = remaining.indexOf(row.identity);
|
||||
if (index >= 0) remaining.splice(index, 1);
|
||||
selected.delete(row.identity);
|
||||
scheduled += 1;
|
||||
}
|
||||
write(remaining);
|
||||
if (scheduled) await options.todaySync.flush();
|
||||
return { scheduled, blocked, remaining:remaining.length };
|
||||
}
|
||||
|
||||
function mount({ qs, escapeHtml, onComplete = () => {} }) {
|
||||
const list = qs('#today-handoff-items');
|
||||
let prompted = false;
|
||||
function open() {
|
||||
if (prompted) return false;
|
||||
const rows = render(null, list, escapeHtml);
|
||||
if (!rows.length) return false;
|
||||
qs('#today-handoff-status').textContent = rows.length +
|
||||
(rows.length === 1 ? ' commitment is ready to review.' : ' commitments are ready to review.');
|
||||
const dialog = qs('#today-handoff-dialog');
|
||||
if (!dialog.open) dialog.showModal();
|
||||
prompted = true;
|
||||
requestAnimationFrame(() => (list.querySelector('input') || qs('#confirm-today-handoff')).focus());
|
||||
return true;
|
||||
}
|
||||
qs('#close-today-handoff').addEventListener('click', () => qs('#today-handoff-dialog').close());
|
||||
qs('#confirm-today-handoff').addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
qs('#today-handoff-status').textContent = 'Saving Today’s plan…';
|
||||
try {
|
||||
const result = await commit();
|
||||
if (result.blocked) qs('#today-handoff-status').textContent =
|
||||
'Today is full. ' + result.remaining + ' commitment remains safe in My Work.';
|
||||
else qs('#today-handoff-dialog').close();
|
||||
onComplete(result);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
return { open, reset:() => { prompted = false; } };
|
||||
}
|
||||
|
||||
return { capture, choose, commit, mount, pending, render, review };
|
||||
});
|
||||
@@ -217,7 +217,7 @@ async function saveTodayRecapTime(payload) {
|
||||
}
|
||||
|
||||
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml,
|
||||
describeWork = () => null, adjustPlan = () => {} }) {
|
||||
describeWork = () => null, openWrapUp = () => {} }) {
|
||||
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
|
||||
const render = () => {
|
||||
const draft = recap.snapshot();
|
||||
@@ -290,7 +290,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#today-recap-status').textContent = 'Recap saved to your account.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(handoff.actual_minutes);
|
||||
openWrapUp(handoff.actual_minutes);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
|
||||
} finally { button.disabled = false; }
|
||||
@@ -310,7 +310,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(actualMinutes);
|
||||
openWrapUp(actualMinutes);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
|
||||
render();
|
||||
@@ -345,8 +345,8 @@ function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
|
||||
}
|
||||
|
||||
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
|
||||
describeWork, adjustPlan) {
|
||||
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
|
||||
describeWork, openWrapUp) {
|
||||
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, openWrapUp };
|
||||
const recap = createTodayRecap({
|
||||
save:saveTodayRecap, saveAndLog:saveTodayRecapTime,
|
||||
clear:() => timer.clearRecap(), storage:localStorage, getLogin,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
|
||||
let items = [];
|
||||
const selected = new Set();
|
||||
|
||||
function open(candidates) {
|
||||
const current = new Set(todayWork.read());
|
||||
const byIdentity = new Map((candidates || []).map(item => [todayWork.identity(item), item]));
|
||||
items = todayWork.read().flatMap(identity =>
|
||||
current.has(identity) && byIdentity.has(identity) ? [byIdentity.get(identity)] : []
|
||||
);
|
||||
selected.clear();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function choose(identity, schedule) {
|
||||
if (!items.some(item => todayWork.identity(item) === identity)) return false;
|
||||
if (schedule) selected.add(identity);
|
||||
else selected.delete(identity);
|
||||
return true;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return items.map(item => ({
|
||||
item,
|
||||
identity:todayWork.identity(item),
|
||||
schedule:selected.has(todayWork.identity(item)),
|
||||
}));
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
const wake = laterWork.presetUntil('tomorrow');
|
||||
let scheduled = 0;
|
||||
for (const item of items) {
|
||||
const identity = todayWork.identity(item);
|
||||
if (!selected.has(identity) || !todayWork.contains(item)) continue;
|
||||
const deferred = laterWork.defer(item, wake, {handoff:'today'});
|
||||
if (deferred !== 'deferred') throw new Error('Tomorrow could not be saved. Your Today plan is unchanged.');
|
||||
if (!todaySync.enqueue('remove', identity)) {
|
||||
laterWork.restore?.(item);
|
||||
throw new Error('Today sync could not be queued. Your Today plan is unchanged.');
|
||||
}
|
||||
if (todayWork.contains(item) && !todayWork.remove(item)) {
|
||||
throw new Error('Today could not be updated. Retry wrap-up.');
|
||||
}
|
||||
scheduled += 1;
|
||||
}
|
||||
await todaySync.flush();
|
||||
return { scheduled, left:items.length - scheduled, wake_at:wake.toISOString() };
|
||||
}
|
||||
|
||||
return { open, choose, snapshot, finish };
|
||||
}
|
||||
|
||||
function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => {}, onClose = () => {} }) {
|
||||
let actualMinutes = {};
|
||||
|
||||
function close() {
|
||||
qs('#today-wrap-up-sheet').hidden = true;
|
||||
document.body.classList.remove('task-overlay-open');
|
||||
onClose();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const rows = controller.snapshot();
|
||||
qs('#today-wrap-up-items').innerHTML = rows.map(row => {
|
||||
const title = String(row.item.title || row.identity).slice(0, 180);
|
||||
const context = String(row.item.key || row.item.repository || 'Work item').slice(0, 180);
|
||||
return '<div class="today-wrap-up-item"><span><strong>' + escapeHtml(title) + '</strong>' +
|
||||
'<span class="small muted">' + escapeHtml(context) + '</span></span><label><input type="checkbox" ' +
|
||||
'data-wrap-up-identity="' + escapeHtml(row.identity) + '"' + (row.schedule ? ' checked' : '') +
|
||||
'> Tomorrow 09:00</label></div>';
|
||||
}).join('') || '<p class="small">Nothing unfinished remains in Today.</p>';
|
||||
qs('#today-wrap-up-items').querySelectorAll('[data-wrap-up-identity]').forEach(input => {
|
||||
input.addEventListener('change', () => controller.choose(input.dataset.wrapUpIdentity, input.checked));
|
||||
});
|
||||
}
|
||||
|
||||
function open(items, recapActualMinutes = {}) {
|
||||
actualMinutes = { ...recapActualMinutes };
|
||||
controller.open(items);
|
||||
qs('#today-wrap-up-status').textContent = '';
|
||||
render();
|
||||
qs('#today-wrap-up-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
requestAnimationFrame(() => (qs('#today-wrap-up-items input') || qs('#finish-today-wrap-up')).focus());
|
||||
}
|
||||
|
||||
async function finish(button) {
|
||||
button.disabled = true;
|
||||
qs('#today-wrap-up-status').textContent = 'Saving tomorrow’s plan…';
|
||||
try {
|
||||
const result = await controller.finish();
|
||||
qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.';
|
||||
close();
|
||||
onComplete(result, actualMinutes);
|
||||
} catch (error) {
|
||||
qs('#today-wrap-up-status').textContent = error.message || 'Wrap-up could not be saved. Retry when ready.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bind() {
|
||||
qs('#close-today-wrap-up').addEventListener('click', close);
|
||||
qs('#finish-today-wrap-up').addEventListener('click', event => finish(event.currentTarget));
|
||||
}
|
||||
|
||||
return { open, close, finish, render, bind };
|
||||
}
|
||||
|
||||
function setupTodayWrapUp(options) {
|
||||
const controller = createTodayWrapUp(options);
|
||||
const view = createTodayWrapUpView({ ...options, controller });
|
||||
view.bind();
|
||||
return view;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodayWrapUp.createView = createTodayWrapUpView;
|
||||
createTodayWrapUp.setup = setupTodayWrapUp;
|
||||
module.exports = createTodayWrapUp;
|
||||
}
|
||||
+43
-9
@@ -7,6 +7,13 @@
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'filed', 'update', 'later', 'draft'];
|
||||
const sections = {
|
||||
issue: ['overview', 'conversation', 'reply', 'actions'],
|
||||
filed: ['overview', 'conversation', 'reply', 'actions'],
|
||||
pull: ['overview', 'conversation', 'reply', 'review'],
|
||||
review: ['overview', 'files', 'feedback', 'history'],
|
||||
update: ['activity', 'conversation', 'context', 'reply'],
|
||||
};
|
||||
|
||||
function positiveInteger(value) {
|
||||
const number = Number(value);
|
||||
@@ -23,27 +30,37 @@
|
||||
if (queue === 'agenda' && parts[3] === 'protect-today' && parts.length === 4) {
|
||||
return { kind: 'queue', filter: 'agenda', action: 'protect-today' };
|
||||
}
|
||||
if (parts[2] === 'update' && parts.length === 4) {
|
||||
if (parts[2] === 'update' && [4, 5].includes(parts.length)) {
|
||||
const notificationId = positiveInteger(parts[3]);
|
||||
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
||||
const section = parts[4];
|
||||
if (!notificationId || (section && !sections.update.includes(section))) return null;
|
||||
return { kind: 'update', notification_id: notificationId, ...(section ? { section } : {}) };
|
||||
}
|
||||
if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
|
||||
if (!['issue', 'filed', 'pull', 'review'].includes(parts[2]) || ![6, 7].includes(parts.length)) return null;
|
||||
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
|
||||
const number = positiveInteger(parts[5]);
|
||||
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
|
||||
const section = parts[6];
|
||||
if (!number || (section && !sections[parts[2]].includes(section))) return null;
|
||||
return {
|
||||
kind: parts[2], repository: parts[3] + '/' + parts[4], number,
|
||||
...(section ? { section } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function serialize(item) {
|
||||
if (item?.kind === 'update') {
|
||||
const notificationId = positiveInteger(item.notification_id);
|
||||
return notificationId ? '#/my-work/update/' + notificationId : '';
|
||||
if (!notificationId || (item.section && !sections.update.includes(item.section))) return '';
|
||||
return '#/my-work/update/' + notificationId + (item.section ? '/' + item.section : '');
|
||||
}
|
||||
const kind = item?.is_filed && !item?.is_assigned ? 'filed' : item?.kind;
|
||||
if (!['issue', 'filed', 'pull', 'review'].includes(kind)) return '';
|
||||
const repository = String(item.repository || '').split('/');
|
||||
const number = positiveInteger(item.number);
|
||||
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
|
||||
return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number;
|
||||
if (item.section && !sections[kind].includes(item.section)) return '';
|
||||
return '#/my-work/' + kind + '/' + repository.join('/') + '/' + number +
|
||||
(item.section ? '/' + item.section : '');
|
||||
}
|
||||
|
||||
function sameRoute(item, route) {
|
||||
@@ -59,7 +76,7 @@
|
||||
|
||||
function createController({
|
||||
location, history, eventTarget, onOpen, onClose, onInvalid,
|
||||
onQueue = function () {},
|
||||
onQueue = function () {}, onSection = function () {},
|
||||
resolve, onResolving = function () {}, onError = function () {},
|
||||
}) {
|
||||
let items = [];
|
||||
@@ -86,7 +103,7 @@
|
||||
return;
|
||||
}
|
||||
active = fragment;
|
||||
onOpen({ ...item, kind: route.kind });
|
||||
onOpen({ ...item, kind: route.kind, ...(route.section ? { section: route.section } : {}) });
|
||||
}).catch(error => {
|
||||
if (request !== resolution || String(location.hash || '') !== fragment) return;
|
||||
resolving = '';
|
||||
@@ -128,10 +145,16 @@
|
||||
return;
|
||||
}
|
||||
if (active === fragment) return;
|
||||
const activeRoute = parse(active);
|
||||
if (activeRoute && sameRoute(item, activeRoute)) {
|
||||
active = fragment;
|
||||
onSection(route.section || sections[route.kind][0], { restore: true });
|
||||
return;
|
||||
}
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
active = fragment;
|
||||
onOpen({ ...item, kind: route.kind });
|
||||
onOpen({ ...item, kind: route.kind, ...(route.section ? { section: route.section } : {}) });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -156,6 +179,17 @@
|
||||
onOpen(item);
|
||||
return true;
|
||||
},
|
||||
section(name, options = {}) {
|
||||
const route = parse(location.hash);
|
||||
if (!route || route.kind === 'queue' || !sections[route.kind].includes(name)) return false;
|
||||
if ((route.section || sections[route.kind][0]) === name) return true;
|
||||
const fragment = serialize({ ...route, section: name });
|
||||
const method = options.replace ? 'replaceState' : 'pushState';
|
||||
history[method]({ workRoute: fragment }, '', fragment);
|
||||
active = fragment;
|
||||
onSection(name, { restore: false });
|
||||
return true;
|
||||
},
|
||||
queue(filter) {
|
||||
if (!queueFilters.includes(filter)) return false;
|
||||
const name = filter + (['update', 'draft'].includes(filter) ? 's' : '');
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
async function loadWorkspace({ document, window = null, createLoader = createFeatureLoader }) {
|
||||
let cameOnline = false;
|
||||
let replayed = false;
|
||||
const captureOnline = () => { cameOnline = true; };
|
||||
window?.addEventListener('online', captureOnline);
|
||||
const status = document.querySelector('#my-work-action-status');
|
||||
const url = document.querySelector(
|
||||
'meta[name="stackchain-feature-today-timer"]'
|
||||
)?.content || '';
|
||||
const loader = createLoader({
|
||||
document,
|
||||
urls: { 'today-timer': url },
|
||||
});
|
||||
if (status) status.textContent = 'Starting workspace…';
|
||||
try {
|
||||
await loader.load('today-timer');
|
||||
if (status) status.textContent = '';
|
||||
return {
|
||||
replayOnline(callback) {
|
||||
if (replayed) return;
|
||||
replayed = true;
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (cameOnline) callback();
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (window?.location?.reload) {
|
||||
if (cameOnline) window.location.reload();
|
||||
else window.addEventListener('online', () => window.location.reload(), { once: true });
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = window
|
||||
? 'Workspace could not load. Reconnect to retry automatically, or reload now.'
|
||||
: 'Workspace could not load. Check your connection, then reload to retry.';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
|
||||
@@ -0,0 +1 @@
|
||||
pip-audit==2.10.1
|
||||
+1
-1
@@ -2,7 +2,7 @@ fastapi==0.133.1
|
||||
httpx==0.28.1
|
||||
pydantic==2.13.4
|
||||
Pillow==12.3.0
|
||||
python-multipart==0.0.22
|
||||
python-multipart==0.0.32
|
||||
pytest==9.1.1
|
||||
pywebpush==2.1.2
|
||||
rjsmin==1.2.5
|
||||
|
||||
+9
-11
@@ -21,17 +21,20 @@ COMMONJS_BROWSER_BRANCH = re.compile(
|
||||
)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
||||
"comment-actions": ("static/comment-actions.js",),
|
||||
"issue-capture": (
|
||||
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.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"),
|
||||
"device-setup": (
|
||||
"static/install-app.js", "static/private-data-inventory.js", "static/private-device-data.js",
|
||||
"static/device-storage.js", "static/mobile-device-setup.js",
|
||||
),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js",
|
||||
"static/today-completion.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
@@ -114,7 +117,6 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</body>",
|
||||
f'<script src="{feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||
f'<script src="{core.runtime_name}"></script>\n</body>',
|
||||
)
|
||||
|
||||
@@ -122,15 +124,11 @@ 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"
|
||||
}
|
||||
optional_features = feature_bundles
|
||||
worker = worker.replace(
|
||||
" BASE + 'static/dashboard.css',\n",
|
||||
" BASE + 'static/dashboard.css',\n"
|
||||
+ f" BASE + '{core.runtime_name}',\n"
|
||||
+ f" BASE + '{eager_feature.runtime_name}',\n",
|
||||
+ f" BASE + '{core.runtime_name}',\n",
|
||||
)
|
||||
worker = worker.replace(
|
||||
"const OPTIONAL_FEATURES = [\n",
|
||||
|
||||
+8
-2
@@ -131,6 +131,7 @@ class LaterStore:
|
||||
item_id: str,
|
||||
*,
|
||||
wake_at: str | None = None,
|
||||
handoff: str | None = None,
|
||||
base_revision: int | None = None,
|
||||
) -> dict:
|
||||
result = self.apply_batch(login, [{
|
||||
@@ -138,6 +139,7 @@ class LaterStore:
|
||||
"action": action,
|
||||
"item_id": item_id,
|
||||
"wake_at": wake_at,
|
||||
"handoff": handoff,
|
||||
"base_revision": base_revision,
|
||||
}])
|
||||
return {"revision": result["revision"], "records": result["records"]}
|
||||
@@ -181,6 +183,9 @@ class LaterStore:
|
||||
wake_at = operation.get("wake_at")
|
||||
if action == "defer":
|
||||
wake_at = self._validate_wake_at(wake_at)
|
||||
handoff = operation.get("handoff")
|
||||
if handoff not in {None, "today"}:
|
||||
raise ValueError("unsupported Later handoff")
|
||||
duplicate = connection.execute(
|
||||
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
|
||||
(login, operation_id),
|
||||
@@ -204,8 +209,9 @@ class LaterStore:
|
||||
|
||||
before = records.get(item_id)
|
||||
if action == "defer":
|
||||
records[item_id] = wake_at
|
||||
changed = before != wake_at
|
||||
record = {"wake_at": wake_at, "handoff": handoff} if handoff else wake_at
|
||||
records[item_id] = record
|
||||
changed = before != record
|
||||
else:
|
||||
changed = item_id in records
|
||||
records.pop(item_id, None)
|
||||
|
||||
@@ -610,6 +610,7 @@ class LaterOperation(BaseModel):
|
||||
action: Literal["defer", "restore"]
|
||||
item_id: str = Field(min_length=1, max_length=500)
|
||||
wake_at: str | None = Field(default=None, max_length=100)
|
||||
handoff: Literal["today"] | None = None
|
||||
base_revision: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
@@ -2628,6 +2629,7 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch):
|
||||
payload.action,
|
||||
payload.item_id,
|
||||
wake_at=payload.wake_at,
|
||||
handoff=payload.handoff,
|
||||
base_revision=payload.base_revision,
|
||||
)
|
||||
except ValueError as error:
|
||||
|
||||
+46
-1
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from threading import Event
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
|
||||
@@ -51,6 +52,10 @@ class FakeGiteaServer(ThreadingHTTPServer):
|
||||
super().__init__(address, FakeGiteaHandler)
|
||||
self.created_issues: list[dict] = []
|
||||
self.issue_creation_enabled = False
|
||||
self.issue_creation_ready = Event()
|
||||
self.assigned_issue_numbers = [issue["number"] for issue in AVAILABLE_ISSUES]
|
||||
self.comments: list[tuple[int, str]] = []
|
||||
self.requests: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
@@ -73,6 +78,7 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
parsed = urlsplit(self.path)
|
||||
path = parsed.path
|
||||
query = parse_qs(parsed.query)
|
||||
self.server.requests.append(("GET", self.path))
|
||||
if path == "/api/v1/user":
|
||||
self._json(200, USER)
|
||||
elif path == "/api/v1/user/repos":
|
||||
@@ -82,17 +88,33 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
elif path == "/api/v1/repos/search":
|
||||
self._json(200, {"data": [REPOSITORY], "ok": True})
|
||||
elif path == "/api/v1/repos/issues/search":
|
||||
is_assigned_scan = query.get("assigned") == ["true"] and query.get("type") == ["issues"]
|
||||
is_available_scan = not any(
|
||||
name in query for name in ("assigned", "created", "review_requested", "q")
|
||||
) and query.get("type") == ["issues"]
|
||||
issues = AVAILABLE_ISSUES if is_available_scan else []
|
||||
assigned = [
|
||||
{**issue, "assignees": [USER]}
|
||||
for issue in AVAILABLE_ISSUES
|
||||
if issue["number"] in self.server.assigned_issue_numbers
|
||||
]
|
||||
available = [
|
||||
issue for issue in AVAILABLE_ISSUES
|
||||
if issue["number"] not in self.server.assigned_issue_numbers
|
||||
]
|
||||
issues = assigned if is_assigned_scan else available if is_available_scan else []
|
||||
self._json(200, issues, **{"X-Total-Count": str(len(issues))})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/comments"):
|
||||
self._json(200, [], **{"X-Total-Count": "0"})
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/dependencies"):
|
||||
self._json(200, [])
|
||||
elif path.startswith("/api/v1/repos/acme/mobile/issues/"):
|
||||
try:
|
||||
number = int(path.rsplit("/", 1)[-1])
|
||||
except ValueError:
|
||||
number = 0
|
||||
issue = next((item for item in AVAILABLE_ISSUES if item["number"] == number), None)
|
||||
if issue and number in self.server.assigned_issue_numbers:
|
||||
issue = {**issue, "assignees": [USER]}
|
||||
self._json(200, issue) if issue else self._json(404, {"message": "not found"})
|
||||
elif path.startswith("/api/v1/"):
|
||||
self._json(200, [], **{"X-Total-Count": "0"})
|
||||
@@ -101,11 +123,34 @@ class FakeGiteaHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
path = urlsplit(self.path).path
|
||||
self.server.requests.append(("POST", self.path))
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||
if path.startswith("/api/v1/repos/acme/mobile/issues/") and path.endswith("/comments"):
|
||||
try:
|
||||
number = int(path.split("/")[-2])
|
||||
except ValueError:
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
body = str(payload.get("body", ""))
|
||||
self.server.comments.append((number, body))
|
||||
self._json(
|
||||
201,
|
||||
{
|
||||
"id": len(self.server.comments),
|
||||
"body": body,
|
||||
"user": USER,
|
||||
"created_at": "2026-08-16T20:00:00Z",
|
||||
"updated_at": "2026-08-16T20:00:00Z",
|
||||
"html_url": f"http://127.0.0.1/acme/mobile/issues/{number}#issuecomment-1",
|
||||
},
|
||||
)
|
||||
return
|
||||
if path != "/api/v1/repos/acme/mobile/issues":
|
||||
self._json(404, {"message": "not found"})
|
||||
return
|
||||
if not self.server.issue_creation_enabled:
|
||||
self.server.issue_creation_ready.wait(timeout=45)
|
||||
if not self.server.issue_creation_enabled:
|
||||
self._json(503, {"message": "release journey is still offline"})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged mobile Home bootstrap runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
failed_responses: list[str] = []
|
||||
workspace_requests: list[str] = []
|
||||
live_requests: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on(
|
||||
"console",
|
||||
lambda message: browser_errors.append(message.text)
|
||||
if message.type == "error"
|
||||
else None,
|
||||
)
|
||||
page.on(
|
||||
"response",
|
||||
lambda response: failed_responses.append(f"{response.status} {response.url}")
|
||||
if response.status >= 400
|
||||
else None,
|
||||
)
|
||||
page.on(
|
||||
"request",
|
||||
lambda request: workspace_requests.append(request.url)
|
||||
if "feature-today-timer-" in request.url
|
||||
else None,
|
||||
)
|
||||
page.on(
|
||||
"request",
|
||||
lambda request: live_requests.append(request.url)
|
||||
if "/api/v1/live" in request.url
|
||||
else None,
|
||||
)
|
||||
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Home bootstrap release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
initial_live_requests = len(live_requests)
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
window.scrollTo(0, 0);
|
||||
const surface = document.querySelector('#my-work');
|
||||
surface.setPointerCapture = () => {};
|
||||
surface.releasePointerCapture = () => {};
|
||||
const pointer = (type, y) => surface.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:y,
|
||||
}));
|
||||
pointer('pointerdown', 10);
|
||||
pointer('pointermove', 90);
|
||||
}
|
||||
"""
|
||||
)
|
||||
expect(page.locator("#mobile-pull-refresh")).to_have_text("Release to refresh")
|
||||
page.evaluate(
|
||||
"document.querySelector('#my-work').dispatchEvent(new PointerEvent('pointerup', "
|
||||
"{bubbles:true, pointerId:41, pointerType:'touch', clientX:100, clientY:90}))"
|
||||
)
|
||||
expect(page.locator("#mobile-pull-refresh")).to_have_text("My Work is up to date")
|
||||
assert len(live_requests) == initial_live_requests + 1
|
||||
dock = page.locator("#mobile-task-dock")
|
||||
expect(dock).to_be_visible()
|
||||
expect(dock.locator("button")).to_have_count(5)
|
||||
for control in dock.locator("button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
||||
page.locator('[data-mobile-task="queues"]').click()
|
||||
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
|
||||
expect(delivery_queue).to_be_visible()
|
||||
expect(delivery_queue).to_contain_text("Delivery")
|
||||
delivery_bounds = delivery_queue.bounding_box()
|
||||
assert delivery_bounds and delivery_bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#close-mobile-queues").click()
|
||||
|
||||
page.locator("#app-menu-toggle").click()
|
||||
page.locator("#open-insights").click()
|
||||
expect(page.locator("#insights-sheet")).to_be_visible()
|
||||
expect(page.locator("#insights-heading")).to_have_text("Insights")
|
||||
page.locator("#close-insights").click()
|
||||
expect(page.locator("#insights-sheet")).to_be_hidden()
|
||||
expect(page.locator("#my-work")).to_be_visible()
|
||||
expect(dock).to_be_visible()
|
||||
|
||||
page.locator("#app-menu-toggle").click()
|
||||
page.locator("#open-device-setup").click()
|
||||
expect(page.locator("#device-setup-sheet")).to_be_visible()
|
||||
expect(page.locator("#device-storage-heading")).to_have_text("Private device storage")
|
||||
expect(page.locator("#device-storage-detail")).to_contain_text("private work records")
|
||||
for selector in ("#clear-device-caches", "#clear-private-device-data"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#close-device-setup").click()
|
||||
expect(page.locator("#device-setup-sheet")).to_be_hidden()
|
||||
|
||||
page.locator("#active-devices").click()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_visible()
|
||||
expect(page.locator("#security-activity-section")).to_be_visible()
|
||||
security_nav = page.locator(".security-section-nav")
|
||||
expect(security_nav).to_be_visible()
|
||||
for control in security_nav.locator("button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator("#security-section-devices").click()
|
||||
expect(page.locator("#security-section-devices")).to_have_attribute("aria-current", "page")
|
||||
page.go_back()
|
||||
expect(page.locator("#security-section-activity")).to_have_attribute("aria-current", "page")
|
||||
page.go_back()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_hidden()
|
||||
expect(page.locator("#active-devices")).to_be_focused()
|
||||
|
||||
assert len(workspace_requests) == 1, workspace_requests
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert browser_errors == []
|
||||
assert failed_responses == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844})
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Delivery recovery release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
window.__deliveryAttempts = 0;
|
||||
window.__deliverySettled = new Promise(resolve => { window.__finishDelivery = resolve; });
|
||||
window.__releaseRecovery = createMobileDeliveryRecovery({
|
||||
getItems: () => [{outbox_id:'release-retry', delivery_state:'waiting', title:'Post release note'}],
|
||||
activate: async () => {
|
||||
window.__deliveryAttempts += 1;
|
||||
await window.__deliverySettled;
|
||||
},
|
||||
});
|
||||
window.__releaseRecovery.open();
|
||||
window.__firstRecovery = window.__releaseRecovery.activate();
|
||||
window.__secondRecovery = window.__releaseRecovery.activate();
|
||||
}
|
||||
"""
|
||||
)
|
||||
action = page.locator("#mobile-delivery-recovery-action")
|
||||
expect(page.locator("#mobile-delivery-recovery")).to_be_visible()
|
||||
expect(action).to_be_disabled()
|
||||
expect(page.locator("#mobile-delivery-recovery-status")).to_have_text("Working…")
|
||||
assert page.evaluate("window.__deliveryAttempts") == 1
|
||||
bounds = action.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.evaluate("window.__finishDelivery()")
|
||||
page.evaluate("Promise.all([window.__firstRecovery, window.__secondRecovery])")
|
||||
expect(action).to_be_enabled()
|
||||
assert page.evaluate("window.__deliveryAttempts") == 1
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const destination = document.createElement('button');
|
||||
destination.type = 'button';
|
||||
destination.className = 'draft-resume';
|
||||
destination.dataset.draftIndex = '0';
|
||||
destination.textContent = 'Open current review';
|
||||
window.__attentionAttempts = 0;
|
||||
window.__attentionSettled = new Promise(resolve => { window.__finishAttention = resolve; });
|
||||
destination.onclick = async () => {
|
||||
window.__attentionAttempts += 1;
|
||||
await window.__attentionSettled;
|
||||
return true;
|
||||
};
|
||||
document.querySelector('#my-work-list').append(destination);
|
||||
window.__attentionRecovery = createMobileDeliveryRecovery({
|
||||
getItems: () => [{outbox_id:'review-attention', status:'attention', title:'Review feedback'}],
|
||||
getIndex: () => 0,
|
||||
});
|
||||
window.__attentionRecovery.open();
|
||||
window.__firstAttention = window.__attentionRecovery.activate();
|
||||
window.__secondAttention = window.__attentionRecovery.activate();
|
||||
}
|
||||
"""
|
||||
)
|
||||
expect(page.locator("#mobile-delivery-recovery")).to_be_hidden()
|
||||
expect(page.locator("#my-work-list .draft-resume")).to_be_focused()
|
||||
assert page.evaluate("window.__attentionAttempts") == 1
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.evaluate("window.__finishAttention()")
|
||||
page.evaluate("Promise.all([window.__firstAttention, window.__secondAttention])")
|
||||
assert page.evaluate("window.__attentionAttempts") == 1
|
||||
assert browser_errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -232,6 +232,10 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
page.locator("#create-issue-repository").select_option("acme/mobile")
|
||||
expect(page.locator("#submit-new-issue")).to_be_enabled()
|
||||
|
||||
# Page offline emulation can leave service-worker requests connected.
|
||||
# Abort the mutation at the browser boundary too, matching a real
|
||||
# transport outage without poisoning server-side idempotency state.
|
||||
context.route("**/api/v1/repos/acme/mobile/issues", lambda route: route.abort())
|
||||
context.set_offline(True)
|
||||
page.locator("#submit-new-issue").click()
|
||||
expect(page.locator("#issue-filing-review")).to_be_visible()
|
||||
@@ -267,6 +271,8 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
browser_errors.clear() # Chromium reports expected network errors while the context is offline.
|
||||
failed_responses.clear()
|
||||
fake.issue_creation_enabled = True
|
||||
fake.issue_creation_ready.set()
|
||||
context.unroute("**/api/v1/repos/acme/mobile/issues")
|
||||
context.set_offline(False)
|
||||
page.evaluate("window.dispatchEvent(new Event('online'))")
|
||||
# Containerized Actions runners can take longer than a local browser to wake the
|
||||
@@ -275,7 +281,22 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
if fake.created_issues:
|
||||
break
|
||||
page.wait_for_timeout(250)
|
||||
assert fake.created_issues == [{"title": TITLE, "body": BODY, "assignee": "timmy"}]
|
||||
local_debug = page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')")
|
||||
durable_debug = indexed_issue_records(page)
|
||||
assert fake.created_issues == [
|
||||
{"title": TITLE, "body": BODY, "assignee": "timmy"}
|
||||
], (
|
||||
f"url={page.url} ready={page.evaluate('document.readyState')} "
|
||||
f"status={page.locator('#my-work-action-status').inner_text()!r} "
|
||||
f"errors={browser_errors[-5:]!r} responses={failed_responses[-10:]!r} "
|
||||
f"local={local_debug!r} durable={durable_debug!r} "
|
||||
f"requests={fake.requests[-20:]!r}"
|
||||
)
|
||||
# Deferred workspace startup can report its already-issued offline request
|
||||
# after the pre-reconnect clear; retain every non-offline browser error.
|
||||
browser_errors[:] = [
|
||||
error for error in browser_errors if "ERR_INTERNET_DISCONNECTED" not in error
|
||||
]
|
||||
|
||||
for _ in range(40):
|
||||
durable_completion = indexed_issue_records(page)
|
||||
@@ -298,6 +319,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
browser.close()
|
||||
finally:
|
||||
fake.issue_creation_ready.set()
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Plan Today handoff journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
failed_responses: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on("console", lambda message: browser_errors.append(message.text) if message.type == "error" else None)
|
||||
page.on(
|
||||
"response",
|
||||
lambda response: failed_responses.append(f"{response.status} {response.url}")
|
||||
if response.status >= 400
|
||||
else None,
|
||||
)
|
||||
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Plan Today release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
expect(page.locator("#plan-today-sheet")).to_be_visible()
|
||||
expect(page.locator("#plan-today-candidates .plan-today-item")).to_have_count(2)
|
||||
|
||||
navigation = page.locator(".mobile-plan-today-nav")
|
||||
expect(navigation).to_be_visible()
|
||||
for control in navigation.locator("button").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
|
||||
page.locator("#plan-today-available").fill("90")
|
||||
page.locator("#plan-today-available").press("Tab")
|
||||
page.locator("#build-today-plan").click()
|
||||
missing_estimates = page.locator("[data-plan-missing-estimate]")
|
||||
expect(missing_estimates).to_have_count(2)
|
||||
for _ in range(2):
|
||||
missing_estimates.nth(0).fill("30")
|
||||
missing_estimates.nth(0).press("Tab")
|
||||
expect(page.locator("#plan-today-list .plan-today-item")).to_have_count(2)
|
||||
estimates = page.locator("[data-plan-estimate]")
|
||||
expect(estimates).to_have_count(2)
|
||||
page.locator("#save-and-start-today").click()
|
||||
|
||||
try:
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden(timeout=5_000)
|
||||
except AssertionError as error:
|
||||
raise AssertionError(page.evaluate("({hidden:document.querySelector('#plan-today-sheet').hidden, state:history.state, bodyClass:document.body.className})")) from error
|
||||
expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open")
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
expect(page.locator("#send-issue-comment-next")).to_be_visible()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
|
||||
page.locator('[data-issue-section="reply"]').click()
|
||||
page.locator("#issue-comment").fill("Handoff complete; continuing with the next Today item.")
|
||||
page.locator("#send-issue-comment-next").click()
|
||||
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Polish desktop filters")
|
||||
expect(page.locator("#issue-comment")).to_have_value("")
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_have_attribute("data-overlay-hidden", "true")
|
||||
expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Polish desktop filters")
|
||||
assert fake.comments == [(41, "Handoff complete; continuing with the next Today item.")]
|
||||
today = page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]')")
|
||||
assert today == ["issue:acme/mobile:42:"]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert browser_errors == []
|
||||
assert failed_responses == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged Today wrap-up journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
|
||||
def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
|
||||
tmp_path: Path, width: int, height: int
|
||||
):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
|
||||
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
browser_errors: list[str] = []
|
||||
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
context = browser.new_context(
|
||||
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||
)
|
||||
page = context.new_page()
|
||||
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||
page.on(
|
||||
"console",
|
||||
lambda message: browser_errors.append(message.text)
|
||||
if message.type == "error"
|
||||
else None,
|
||||
)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Today wrap-up release phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const items=[
|
||||
{kind:'issue',repository:'acme/mobile',number:41,title:'Ship mobile capture',key:'acme/mobile#41'},
|
||||
{kind:'issue',repository:'acme/mobile',number:42,title:'Polish desktop filters',key:'acme/mobile#42'},
|
||||
];
|
||||
const identity=item => `${item.kind}:${item.repository}:${item.number}:`;
|
||||
const today=items.map(identity); const later={}; const operations=[];
|
||||
const todayWork={identity,read:()=>[...today],contains:item=>today.includes(identity(item)),remove:item=>{
|
||||
const index=today.indexOf(identity(item)); if(index<0)return false; today.splice(index,1); return true;
|
||||
}};
|
||||
const laterWork={presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:(item,wake)=>{
|
||||
later[identity(item)]=wake.toISOString(); return 'deferred';
|
||||
}};
|
||||
const todaySync={enqueue:(action,id)=>{operations.push([action,id]);return true;},flush:()=>Promise.resolve(true)};
|
||||
const controller=createTodayWrapUp({todayWork,laterWork,todaySync});
|
||||
const view=createTodayWrapUpView({controller,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
|
||||
window.__wrapTest={view,today,later,operations};
|
||||
view.open(items,{});
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
sheet = page.locator("#today-wrap-up-sheet")
|
||||
expect(sheet).to_be_visible()
|
||||
expect(page.locator(".today-wrap-up-item")).to_have_count(2)
|
||||
for control in sheet.locator("button, label").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator('[data-wrap-up-identity="issue:acme/mobile:41:"]').check()
|
||||
page.evaluate("window.__wrapTest.view.finish(document.querySelector('#finish-today-wrap-up'))")
|
||||
expect(sheet).to_be_hidden()
|
||||
result = page.evaluate(
|
||||
"({today:window.__wrapTest.today,later:window.__wrapTest.later,operations:window.__wrapTest.operations})"
|
||||
)
|
||||
assert result == {
|
||||
"today": ["issue:acme/mobile:42:"],
|
||||
"later": {"issue:acme/mobile:41:": "2026-08-17T09:00:00.000Z"},
|
||||
"operations": [["remove", "issue:acme/mobile:41:"]],
|
||||
}
|
||||
assert browser_errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged next-day Today handoff runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
from fake_gitea import FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path):
|
||||
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||
assert len(archives) == 1
|
||||
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
errors: list[str] = []
|
||||
try:
|
||||
with release_server(archives[0], tmp_path, f"http://127.0.0.1:{fake.server_port}") as origin, sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||
page = browser.new_page(viewport={"width": 390, "height": 844})
|
||||
page.on("pageerror", lambda error: errors.append(error.stack or str(error)))
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Tomorrow review phone")
|
||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||
page.locator("#submit-sign-in").click()
|
||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||
page.evaluate("""localStorage.setItem('stackchain.today-handoff.v1.timmy', JSON.stringify([
|
||||
'issue:acme/mobile:41:', 'issue:acme/mobile:42:'
|
||||
]))""")
|
||||
page.reload(wait_until="networkidle")
|
||||
|
||||
dialog = page.locator("#today-handoff-dialog")
|
||||
expect(dialog).to_be_visible()
|
||||
expect(page.locator("#today-handoff-items .today-handoff-item")).to_have_count(2)
|
||||
for control in dialog.locator("button, input").all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 24
|
||||
buttons = dialog.locator("button")
|
||||
for control in buttons.all():
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
page.locator('[data-today-handoff="issue:acme/mobile:42:"]').uncheck()
|
||||
page.locator("#confirm-today-handoff").click()
|
||||
expect(dialog).to_be_hidden()
|
||||
assert page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy'))") == [
|
||||
"issue:acme/mobile:41:"
|
||||
]
|
||||
assert page.evaluate("JSON.parse(localStorage.getItem('stackchain.today-handoff.v1.timmy'))") == [
|
||||
"issue:acme/mobile:42:"
|
||||
]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
@@ -17,6 +17,23 @@ def test_ci_installs_declared_requirements_before_tests():
|
||||
assert install < tests
|
||||
|
||||
|
||||
def test_ci_audits_pinned_runtime_dependencies_before_tests_and_release_build():
|
||||
text = WORKFLOW.read_text()
|
||||
lint = text[text.index(" lint:") : text.index(" build-release:")]
|
||||
build = text[text.index(" build-release:") : text.index(" browser-journey:")]
|
||||
|
||||
audit_install = lint.index("pip install -r requirements-audit.txt")
|
||||
audit = lint.index("python3 -m pip_audit -r requirements.txt --strict")
|
||||
tests = lint.index("python3 -m pytest tests/ -q")
|
||||
assert audit_install < audit < tests
|
||||
assert "needs: lint" in build
|
||||
|
||||
|
||||
def test_runtime_multipart_parser_uses_advisory_fixed_release():
|
||||
requirements = Path("requirements.txt").read_text().splitlines()
|
||||
assert "python-multipart==0.0.32" in requirements
|
||||
|
||||
|
||||
def test_release_promotion_waits_for_tests_and_bundle():
|
||||
text = WORKFLOW.read_text()
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
@@ -41,6 +58,10 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||
assert (
|
||||
"python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py "
|
||||
"tests/e2e/test_mobile_search_preview_navigation.py "
|
||||
"tests/e2e/test_mobile_find_work_release.py -q"
|
||||
"tests/e2e/test_mobile_find_work_release.py "
|
||||
"tests/e2e/test_mobile_home_bootstrap_release.py "
|
||||
"tests/e2e/test_mobile_today_handoff_release.py "
|
||||
"tests/e2e/test_mobile_today_wrap_up_release.py "
|
||||
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
|
||||
) in browser
|
||||
assert "needs: [lint, build-release, browser-journey]" in release
|
||||
@@ -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-v114" in worker
|
||||
assert "stackchain-dashboard-shell-v117" in worker
|
||||
@@ -850,10 +850,22 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
assert 'aria-labelledby="enrolled-passkeys-title"' in html
|
||||
assert 'id="enrolled-passkeys-list"' in html
|
||||
assert 'A recovery token will be required after sign-out if you remove this device’s passkey.' in html
|
||||
assert 'class="security-section-nav" aria-label="Security Center sections"' in html
|
||||
assert 'id="security-section-activity"' in html
|
||||
assert 'id="security-section-devices"' in html
|
||||
assert 'id="security-section-passkeys"' in html
|
||||
assert 'id="security-activity-section"' in html
|
||||
assert 'id="security-devices-section"' in html
|
||||
assert 'id="security-passkeys-section"' in html
|
||||
assert html.index('id="security-activity-section"') < html.index('id="security-devices-section"')
|
||||
assert 'aria-labelledby="security-activity-title"' in html
|
||||
assert 'id="security-activity-list"' in html
|
||||
assert '>Load older activity</button>' in html
|
||||
assert '#sign-out-all { min-height:44px; }' in html
|
||||
assert '.security-section-nav { position:sticky;' in html
|
||||
assert '.security-section-nav button { min-height:44px;' in html
|
||||
assert '.security-event button { min-height:44px;' in html
|
||||
assert '.security-activity, .security-devices, .enrolled-passkeys { scroll-margin-top:' in html
|
||||
|
||||
|
||||
def test_security_activity_names_confirmed_gitea_time_logging():
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "device-storage.js"
|
||||
|
||||
|
||||
def run_scenario(script: str) -> dict:
|
||||
harness = r"""
|
||||
const createDeviceStorage = require(__MODULE__);
|
||||
class Target {
|
||||
constructor() { this.listeners={}; this.textContent=''; this.hidden=false; this.disabled=false; this.dataset={}; }
|
||||
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
|
||||
async dispatch(name) { for (const callback of this.listeners[name] || []) await callback(); }
|
||||
}
|
||||
const summary=new Target(), detail=new Target(), clearCachesButton=new Target(), clearAllButton=new Target();
|
||||
let purgeCalls=0, purgeError='';
|
||||
let inventory={recordCount:0, unavailable:false};
|
||||
const localStorage={
|
||||
values:new Map([['stackchain.draft.1','private'],['other.preference','keep']]),
|
||||
get length(){return this.values.size;},
|
||||
key(index){return Array.from(this.values.keys())[index] || null;},
|
||||
};
|
||||
const caches={
|
||||
values:new Set(['stackchain-dashboard-shell-a1','other-app']),
|
||||
async keys(){return Array.from(this.values);},
|
||||
async delete(key){return this.values.delete(key);},
|
||||
};
|
||||
const controller=createDeviceStorage({
|
||||
summary, detail, clearCachesButton, clearAllButton, localStorage, caches,
|
||||
privateDatabases:['stackchain-background-outbox-v1','stackchain-offline-work-v2'],
|
||||
inspectPrivateDatabases:async()=>inventory,
|
||||
storageManager:{estimate:async()=>({usage:2 * 1024 * 1024, quota:10 * 1024 * 1024})},
|
||||
clearPrivateDeviceData:async()=>{purgeCalls++;if(purgeError)throw new Error(purgeError);},
|
||||
});
|
||||
(async()=>{ __SCENARIO__ })().catch(error=>{console.error(error);process.exit(1);});
|
||||
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
|
||||
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_inventory_reports_private_categories_and_quota_without_content():
|
||||
result = run_scenario("""
|
||||
await controller.refresh();
|
||||
process.stdout.write(JSON.stringify({summary:summary.textContent,detail:detail.textContent}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"summary": "2 MB of 10 MB browser storage used.",
|
||||
"detail": "1 private browser item · 0 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_cache_only_cleanup_preserves_private_work_and_other_apps():
|
||||
result = run_scenario("""
|
||||
await controller.start();
|
||||
await clearCachesButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
caches:Array.from(caches.values),
|
||||
storage:Array.from(localStorage.values.keys()),
|
||||
status:summary.textContent,
|
||||
detail:detail.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"caches": ["other-app"],
|
||||
"storage": ["stackchain.draft.1", "other.preference"],
|
||||
"status": "Cached app copies cleared. Private work was kept.",
|
||||
"detail": "1 private browser item · 0 private work records · 0 cached app copies",
|
||||
}
|
||||
|
||||
|
||||
def test_full_cleanup_requires_explicit_second_press_when_private_work_exists():
|
||||
result = run_scenario("""
|
||||
await controller.start();
|
||||
await clearAllButton.dispatch('click');
|
||||
const warning={calls:purgeCalls,label:clearAllButton.textContent,status:summary.textContent};
|
||||
await clearAllButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({warning,calls:purgeCalls,status:summary.textContent,detail:detail.textContent}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"warning": {
|
||||
"calls": 0,
|
||||
"label": "Confirm: clear private work",
|
||||
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
|
||||
},
|
||||
"calls": 1,
|
||||
"status": "All Stackchain private data was cleared from this device.",
|
||||
"detail": "0 private browser items · 0 private work records · 0 cached app copies",
|
||||
}
|
||||
|
||||
|
||||
def test_indexeddb_only_work_requires_confirmation_before_purge():
|
||||
result = run_scenario("""
|
||||
localStorage.values.clear();
|
||||
inventory={recordCount:3, unavailable:false};
|
||||
await controller.start();
|
||||
await clearAllButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
calls:purgeCalls,
|
||||
label:clearAllButton.textContent,
|
||||
status:summary.textContent,
|
||||
detail:detail.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 0,
|
||||
"label": "Confirm: clear private work",
|
||||
"status": "Private drafts or queued work may not be synced. Press confirm to clear them from this device.",
|
||||
"detail": "0 private browser items · 3 private work records · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_unavailable_indexeddb_inventory_requires_confirmation_before_purge():
|
||||
result = run_scenario("""
|
||||
localStorage.values.clear();
|
||||
inventory={recordCount:0, unavailable:true};
|
||||
await controller.start();
|
||||
await clearAllButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
calls:purgeCalls,
|
||||
label:clearAllButton.textContent,
|
||||
detail:detail.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 0,
|
||||
"label": "Confirm: clear private work",
|
||||
"detail": "0 private browser items · private work status unknown · 1 cached app copy",
|
||||
}
|
||||
|
||||
|
||||
def test_full_cleanup_reports_blocked_deletion_without_claiming_success():
|
||||
result = run_scenario("""
|
||||
purgeError='IndexedDB deletion was blocked.';
|
||||
await controller.start();
|
||||
await clearAllButton.dispatch('click');
|
||||
let escaped='';
|
||||
try { await clearAllButton.dispatch('click'); } catch (error) { escaped=error.message; }
|
||||
process.stdout.write(JSON.stringify({escaped,status:summary.textContent,disabled:clearAllButton.disabled}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"escaped": "",
|
||||
"status": "Private data was not fully cleared: IndexedDB deletion was blocked.",
|
||||
"disabled": False,
|
||||
}
|
||||
@@ -213,6 +213,37 @@ process.stdout.write(JSON.stringify({{item,partition:inbox.partition()}}));
|
||||
assert output["partition"]["retryable"] == []
|
||||
|
||||
|
||||
def test_delivery_partition_counts_human_action_and_orders_it_before_waiting_work():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
const values = new Map([
|
||||
['stackchain.issue-outbox.v1', JSON.stringify({{version:3,items:[
|
||||
{{id:'waiting',repository:'o/r',title:'Waiting',body:'',ownerLogin:'timmy',status:'queued',queuedAt:500}},
|
||||
{{id:'uncertain',repository:'o/r',title:'Verify',body:'',ownerLogin:'timmy',status:'queued',deliveryState:'uncertain',queuedAt:100}},
|
||||
{{id:'sending',repository:'o/r',title:'Sending',body:'',ownerLogin:'timmy',status:'sending',queuedAt:400}},
|
||||
{{id:'attention',repository:'o/r',title:'Fix me',body:'',ownerLogin:'timmy',status:'attention',queuedAt:200}},
|
||||
]}})],
|
||||
['stackchain.authored-outbox.v1', JSON.stringify({{version:2,items:[
|
||||
{{id:'authorize',kind:'pull-review',repository:'o/r',number:8,body:'Review',ownerLogin:'timmy',status:'authorization',queuedAt:300}},
|
||||
]}})],
|
||||
]);
|
||||
const storage={{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const partition=createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}).partition();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
actionable:partition.actionable,
|
||||
titles:partition.deliveries.map(item=>item.title),
|
||||
waiting:partition.counts.waiting,
|
||||
sending:partition.counts.sending,
|
||||
}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"actionable": 3,
|
||||
"titles": ["o/r#8", "Fix me", "Verify", "Waiting", "Sending"],
|
||||
"waiting": 2,
|
||||
"sending": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_draft_inbox_exposes_created_issue_waiting_for_create_and_start_continuation():
|
||||
script = f"""
|
||||
const createDraftInbox = require({json.dumps(str(DRAFTS))});
|
||||
@@ -347,6 +378,10 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
|
||||
assert "item.last_attempt_error ? '<span class=\"delivery-attempt small\">Last attempt '" in html
|
||||
assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html
|
||||
assert '.delivery-center button { min-height:44px;' in html
|
||||
assert "counts.delivery = draftInbox.partition(lastDrafts).actionable;" in html
|
||||
assert "openDelivery: () => mobileDeliveryRecovery.open()" in html
|
||||
assert "getItems: () => draftInbox.partition(lastDrafts).deliveries" in html
|
||||
assert "authoritativePhases:['delivery']" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -26,8 +26,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
assert first.runtime_gzip_bytes == second.runtime_gzip_bytes
|
||||
assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes
|
||||
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
|
||||
assert first.dashboard_html.count("<script src=") == 2
|
||||
assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' in first.dashboard_html
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' not in first.dashboard_html
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
assert first.runtime_name.startswith("runtime-")
|
||||
assert first.runtime_name.endswith(".js")
|
||||
@@ -51,10 +51,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
|
||||
"today-timer", "security-center",
|
||||
}
|
||||
assert (
|
||||
f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||
f'<script src="{first.runtime_name}"></script>'
|
||||
) in first.dashboard_html
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||
capture = first.feature_bundles["issue-capture"]
|
||||
pull_workflow = first.feature_bundles["pull-workflow"]
|
||||
assert b"function createIssueCapture" not in first.runtime_bytes
|
||||
@@ -72,6 +70,10 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert b"function createAssignAndStart" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createQueueToday" not in first.runtime_bytes
|
||||
assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
|
||||
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
|
||||
assert b"function createMobileDeliveryRecovery" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||
assert b"gitea_time_logged" in security_center.runtime_bytes
|
||||
# Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions.
|
||||
@@ -87,13 +89,10 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
"const OPTIONAL_FEATURES = [", 1
|
||||
)
|
||||
optional_block = optional_block.split("];", 1)[0]
|
||||
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" in shell_block
|
||||
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" not 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
|
||||
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)
|
||||
|
||||
@@ -65,6 +65,30 @@ def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path
|
||||
) == {"revision": 2, "records": {}}
|
||||
|
||||
|
||||
def test_next_day_today_handoff_is_preserved_without_changing_legacy_deferrals(tmp_path):
|
||||
store = LaterStore(tmp_path / "later.sqlite3")
|
||||
|
||||
legacy = store.apply(
|
||||
"timmy", "legacy", "defer", "issue:r:1:",
|
||||
wake_at="2026-08-17T09:00:00.000Z",
|
||||
)
|
||||
handoff = store.apply(
|
||||
"timmy", "wrap-up", "defer", "issue:r:2:",
|
||||
wake_at="2026-08-17T09:00:00.000Z",
|
||||
handoff="today",
|
||||
)
|
||||
|
||||
assert legacy["records"]["issue:r:1:"] == "2026-08-17T09:00:00.000Z"
|
||||
assert handoff["records"] == {
|
||||
"issue:r:1:": "2026-08-17T09:00:00.000Z",
|
||||
"issue:r:2:": {
|
||||
"wake_at": "2026-08-17T09:00:00.000Z",
|
||||
"handoff": "today",
|
||||
},
|
||||
}
|
||||
assert LaterStore(store.path).get("timmy") == handoff
|
||||
|
||||
|
||||
def test_initialized_later_reads_remain_available_during_a_planning_write(tmp_path):
|
||||
path = tmp_path / "later.sqlite3"
|
||||
store = LaterStore(path, timeout=0.05)
|
||||
|
||||
@@ -97,6 +97,35 @@ sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||
assert result["pending"] == []
|
||||
|
||||
|
||||
def test_today_handoff_intent_replays_through_account_sync():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map(); const requests=[];
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'wrap-up-op',
|
||||
fetchJson:async(_url,options={{}})=>{{
|
||||
const body=JSON.parse(options.body); requests.push(body);
|
||||
return {{revision:1,records:{{[body.operations[0].item_id]:{{wake_at:body.operations[0].wake_at,handoff:body.operations[0].handoff}}}},accepted_operation_ids:['wrap-up-op'],duplicate_operation_ids:[],rejected_operations:[]}};
|
||||
}},onRemoteRecords:records=>{{globalThis.records=records}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.enqueue('defer','issue:r:2:','2026-08-17T09:00:00.000Z','today');
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{requests,records:globalThis.records}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["requests"] == [{"operations": [{
|
||||
"operation_id": "wrap-up-op",
|
||||
"action": "defer",
|
||||
"item_id": "issue:r:2:",
|
||||
"wake_at": "2026-08-17T09:00:00.000Z",
|
||||
"handoff": "today",
|
||||
"base_revision": 0,
|
||||
}]}]
|
||||
assert result["records"] == {"issue:r:2:": {
|
||||
"wake_at": "2026-08-17T09:00:00.000Z", "handoff": "today",
|
||||
}}
|
||||
|
||||
|
||||
def test_later_queue_expires_ancient_edits_before_replay():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
@@ -294,6 +323,26 @@ sync.startLifecycle({{window:{{addEventListener:(n,h)=>handlers[n]=h}},document:
|
||||
}
|
||||
|
||||
|
||||
def test_unsynced_wrap_up_handoff_migrates_after_browser_restart():
|
||||
script = f"""
|
||||
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||
const values=new Map();const requests=[];
|
||||
const sync=createLaterSync({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'migrated-handoff',
|
||||
fetchJson:async(_url,options={{}})=>{{const body=JSON.parse(options.body);requests.push(body);return {{revision:1,records:{{}},accepted_operation_ids:['migrated-handoff'],duplicate_operation_ids:[],rejected_operations:[]}}}},
|
||||
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
||||
}});
|
||||
sync.migrate({{'issue:r:1:':{{wake_at:'2026-08-17T09:00:00.000Z',handoff:'today'}}}});
|
||||
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify(requests));}})();
|
||||
"""
|
||||
assert run_node(script) == [{"operations": [{
|
||||
"operation_id": "migrated-handoff", "action": "defer",
|
||||
"item_id": "issue:r:1:", "wake_at": "2026-08-17T09:00:00.000Z",
|
||||
"handoff": "today", "base_revision": 0,
|
||||
}]}]
|
||||
|
||||
|
||||
def test_later_work_emits_local_changes_adopts_remote_truth_and_retires_expiry():
|
||||
script = f"""
|
||||
const createLaterWork=require({json.dumps(str(LATER_WORK))});
|
||||
@@ -324,6 +373,45 @@ process.stdout.write(JSON.stringify({{changes,expired,remote:remote.later,awake:
|
||||
assert result["expired"] == [["issue:stackchain/api:17:"]]
|
||||
|
||||
|
||||
def test_later_work_preserves_synced_today_handoff_until_it_wakes():
|
||||
script = f"""
|
||||
const createLaterWork=require({json.dumps(str(LATER_WORK))});
|
||||
const values=new Map(); const changes=[]; const expired=[];
|
||||
let clock=new Date('2026-08-16T20:00:00Z');
|
||||
const item={{kind:'issue',repository:'stackchain/dashboard',number:992,title:'Commitment'}};
|
||||
const work=createLaterWork({{
|
||||
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||
getLogin:()=> 'timmy',now:()=>clock,setTimer:()=>1,clearTimer:()=>{{}},
|
||||
onChange:(action,id,wakeAt,handoff)=>changes.push([action,id,wakeAt,handoff||null]),
|
||||
onExpire:(ids,handoffs)=>expired.push([ids,handoffs]),
|
||||
}});
|
||||
work.defer(item,new Date('2026-08-17T09:00:00Z'),{{handoff:'today'}});
|
||||
const stored=work.read();
|
||||
clock=new Date('2026-08-17T09:00:01Z');
|
||||
const awake=work.partition([item]);
|
||||
process.stdout.write(JSON.stringify({{stored,changes,expired,awake:awake.active}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["stored"] == {
|
||||
"issue:stackchain/dashboard:992:": {
|
||||
"wake_at": "2026-08-17T09:00:00.000Z",
|
||||
"handoff": "today",
|
||||
}
|
||||
}
|
||||
assert result["changes"] == [[
|
||||
"defer", "issue:stackchain/dashboard:992:",
|
||||
"2026-08-17T09:00:00.000Z", "today",
|
||||
]]
|
||||
assert result["expired"] == [[
|
||||
["issue:stackchain/dashboard:992:"],
|
||||
["issue:stackchain/dashboard:992:"],
|
||||
]]
|
||||
assert result["awake"] == [{
|
||||
"kind": "issue", "repository": "stackchain/dashboard",
|
||||
"number": 992, "title": "Commitment",
|
||||
}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
html = await dashboard()
|
||||
@@ -332,9 +420,9 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
assert 'id="later-sync-status"' in html
|
||||
assert "const laterSync = createLaterSync({" in html
|
||||
assert "onRemoteRecords: records =>" in html
|
||||
assert "onChange: (action, itemId, wakeAt) =>" in html
|
||||
assert "onExpire: ids =>" in html
|
||||
assert "laterSync.enqueue(action, itemId, wakeAt)" in html
|
||||
assert "onChange: (action, itemId, wakeAt, handoff) =>" in html
|
||||
assert "onExpire: (ids, handoffs) =>" in html
|
||||
assert "laterSync.enqueue(action, itemId, wakeAt, handoff)" in html
|
||||
assert "ids.map(id => laterSync.enqueue('restore', id)).every(Boolean)" in html
|
||||
assert "laterSync.migrate(laterWork.read());" in html
|
||||
assert "laterSync.flush();" in html
|
||||
@@ -347,5 +435,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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -569,6 +569,51 @@ async function destination(continuation) {{
|
||||
}
|
||||
|
||||
|
||||
def test_token_and_passkey_login_resume_only_the_private_share_bundle():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
const credential = {{
|
||||
id:'credential-id', type:'public-key', rawId:Uint8Array.from([1]).buffer,
|
||||
response:{{authenticatorData:Uint8Array.from([2]).buffer,
|
||||
clientDataJSON:Uint8Array.from([3]).buffer,
|
||||
signature:Uint8Array.from([4]).buffer,userHandle:null}},
|
||||
}};
|
||||
async function destination(continuation, authentication) {{
|
||||
let replaced = null;
|
||||
const controller = createLoginController({{
|
||||
form:{{reset:()=>{{}}}}, status:{{textContent:''}}, button:{{disabled:false}},
|
||||
passkeyButton:{{disabled:false}}, credentials:{{get:async()=>credential}},
|
||||
fetchImpl:async url => url.endsWith('/options')
|
||||
? new Response(JSON.stringify({{challenge:'AQID',allowCredentials:[]}}),
|
||||
{{status:200,headers:{{'Content-Type':'application/json'}}}})
|
||||
: new Response('{{}}',{{status:200}}),
|
||||
location:{{replace:value=>replaced=value}}, continuation,
|
||||
}});
|
||||
if (authentication === 'passkey') await controller.signInWithPasskey('Phone');
|
||||
else await controller.submit('operator-token');
|
||||
return replaced;
|
||||
}}
|
||||
(async()=>process.stdout.write(JSON.stringify({{
|
||||
token:await destination('./?launch=new&shared=bundle','token'),
|
||||
passkey:await destination('./?launch=new&shared=bundle','passkey'),
|
||||
extra:await destination('./?launch=new&shared=bundle&title=leak','token'),
|
||||
duplicate:await destination('./?launch=new&shared=bundle&shared=bundle','token'),
|
||||
wrongLaunch:await destination('./?launch=agenda&shared=bundle','token'),
|
||||
}})))().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"token": "./?launch=new&shared=bundle",
|
||||
"passkey": "./?launch=new&shared=bundle",
|
||||
"extra": "./",
|
||||
"duplicate": "./",
|
||||
"wrongLaunch": "./",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_page_loads_rate_limit_controller():
|
||||
html = await login()
|
||||
|
||||
@@ -256,4 +256,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-v114" in worker
|
||||
assert "stackchain-dashboard-shell-v117" in worker
|
||||
@@ -45,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-v114" in worker
|
||||
assert "stackchain-dashboard-shell-v117" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RECOVERY = ROOT / "frontend" / "mobile-delivery-recovery.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
cwd=ROOT,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_recovery_orders_deliveries_by_safest_required_intervention():
|
||||
script = f"""
|
||||
const createRecovery = require({json.dumps(str(RECOVERY))});
|
||||
const recovery = createRecovery({{
|
||||
getItems: () => [
|
||||
{{outbox_id:'waiting', delivery_state:'waiting', title:'Waiting'}},
|
||||
{{outbox_id:'uncertain', delivery_state:'uncertain', title:'Uncertain'}},
|
||||
{{outbox_id:'attention', status:'attention', title:'Attention'}},
|
||||
{{outbox_id:'authorization', status:'authorization', title:'Authorization'}},
|
||||
],
|
||||
}});
|
||||
process.stdout.write(JSON.stringify(recovery.snapshot()));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"count": 4,
|
||||
"current": {
|
||||
"id": "authorization",
|
||||
"state": "authorization",
|
||||
"primary": "Authorize & send",
|
||||
"position": 1,
|
||||
"total": 4,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_recovery_advances_only_after_the_current_delivery_is_resolved():
|
||||
script = f"""
|
||||
const createRecovery = require({json.dumps(str(RECOVERY))});
|
||||
(async () => {{
|
||||
let items = [
|
||||
{{outbox_id:'authorize', status:'authorization', title:'Approve review'}},
|
||||
{{outbox_id:'retry', delivery_state:'waiting', title:'Post comment'}},
|
||||
];
|
||||
let resolves = false;
|
||||
const recovery = createRecovery({{
|
||||
getItems: () => items,
|
||||
activate: async item => {{
|
||||
if (resolves) items = items.filter(candidate => candidate.outbox_id !== item.outbox_id);
|
||||
return resolves;
|
||||
}},
|
||||
}});
|
||||
recovery.open();
|
||||
const retained = await recovery.activate();
|
||||
resolves = true;
|
||||
const advanced = await recovery.activate();
|
||||
process.stdout.write(JSON.stringify({{retained, advanced}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"retained": {
|
||||
"count": 2,
|
||||
"current": {
|
||||
"id": "authorize",
|
||||
"state": "authorization",
|
||||
"primary": "Authorize & send",
|
||||
"position": 1,
|
||||
"total": 2,
|
||||
},
|
||||
},
|
||||
"advanced": {
|
||||
"count": 1,
|
||||
"current": {
|
||||
"id": "retry",
|
||||
"state": "waiting",
|
||||
"primary": "Retry now",
|
||||
"position": 1,
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_recovery_keeps_one_attempt_locked_until_delivery_settles():
|
||||
script = f"""
|
||||
const createRecovery = require({json.dumps(str(RECOVERY))});
|
||||
(async () => {{
|
||||
let finish;
|
||||
let attempts = 0;
|
||||
const settled = new Promise(resolve => {{finish = resolve}});
|
||||
const element = () => ({{textContent:'', disabled:false}});
|
||||
const elements = {{
|
||||
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
|
||||
title:element(), destination:element(), reason:element(), progress:element(),
|
||||
action:element(), status:element(),
|
||||
}};
|
||||
const recovery = createRecovery({{
|
||||
getItems: () => [{{outbox_id:'retry', delivery_state:'waiting', title:'Post comment'}}],
|
||||
activate: async () => {{attempts += 1; await settled; return true}},
|
||||
elements,
|
||||
}});
|
||||
recovery.open();
|
||||
const first = recovery.activate();
|
||||
const second = recovery.activate();
|
||||
await Promise.resolve();
|
||||
const pending = {{attempts, disabled:elements.action.disabled, status:elements.status.textContent}};
|
||||
finish();
|
||||
await Promise.all([first, second]);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
pending, attempts, disabled:elements.action.disabled,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"pending": {"attempts": 1, "disabled": True, "status": "Working…"},
|
||||
"attempts": 1,
|
||||
"disabled": False,
|
||||
}
|
||||
|
||||
|
||||
def test_attention_handoff_closes_modal_focuses_destination_and_awaits_work():
|
||||
script = f"""
|
||||
const createRecovery = require({json.dumps(str(RECOVERY))});
|
||||
(async () => {{
|
||||
let finish;
|
||||
let handoffs = 0;
|
||||
const settled = new Promise(resolve => {{finish = resolve}});
|
||||
const destination = {{
|
||||
focused:false, scrolled:false,
|
||||
focus(){{this.focused=true}}, scrollIntoView(){{this.scrolled=true}},
|
||||
async onclick(){{handoffs += 1; await settled; return true}},
|
||||
}};
|
||||
global.document = {{
|
||||
getElementById: () => null,
|
||||
querySelector: selector => selector.includes('draft-resume') ? destination : null,
|
||||
}};
|
||||
const element = () => ({{textContent:'', disabled:false}});
|
||||
const elements = {{
|
||||
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
|
||||
title:element(), destination:element(), reason:element(), progress:element(),
|
||||
action:element(), status:element(),
|
||||
}};
|
||||
const recovery = createRecovery({{
|
||||
getItems: () => [{{outbox_id:'review', status:'attention', title:'Review feedback'}}],
|
||||
getIndex: () => 3,
|
||||
elements,
|
||||
}});
|
||||
recovery.open();
|
||||
const first = recovery.activate();
|
||||
const second = recovery.activate();
|
||||
await Promise.resolve();
|
||||
const pending = {{
|
||||
dialogOpen:elements.dialog.open, disabled:elements.action.disabled,
|
||||
focused:destination.focused, scrolled:destination.scrolled, handoffs,
|
||||
}};
|
||||
finish();
|
||||
await Promise.all([first, second]);
|
||||
process.stdout.write(JSON.stringify({{pending, handoffs, dialogOpen:elements.dialog.open}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"pending": {
|
||||
"dialogOpen": False,
|
||||
"disabled": True,
|
||||
"focused": True,
|
||||
"scrolled": True,
|
||||
"handoffs": 1,
|
||||
},
|
||||
"handoffs": 1,
|
||||
"dialogOpen": False,
|
||||
}
|
||||
|
||||
|
||||
def test_failed_attention_handoff_reopens_the_same_retryable_item():
|
||||
script = f"""
|
||||
const createRecovery = require({json.dumps(str(RECOVERY))});
|
||||
(async () => {{
|
||||
const destination = {{
|
||||
focus(){{}}, scrollIntoView(){{}},
|
||||
async onclick(){{throw new Error('Latest checklist unavailable')}},
|
||||
}};
|
||||
global.document = {{
|
||||
getElementById: () => null,
|
||||
querySelector: () => destination,
|
||||
}};
|
||||
const element = () => ({{textContent:'', disabled:false}});
|
||||
const elements = {{
|
||||
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
|
||||
title:element(), destination:element(), reason:element(), progress:element(),
|
||||
action:element(), status:element(),
|
||||
}};
|
||||
const recovery = createRecovery({{
|
||||
getItems: () => [{{outbox_id:'conflict', checklist_conflict:true, title:'Checklist conflict'}}],
|
||||
getIndex: () => 0,
|
||||
elements,
|
||||
}});
|
||||
recovery.open();
|
||||
const result = await recovery.activate();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
result, dialogOpen:elements.dialog.open, status:elements.status.textContent,
|
||||
action:elements.action.textContent,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"count": 1,
|
||||
"current": {
|
||||
"id": "conflict",
|
||||
"state": "attention",
|
||||
"primary": "Review changes",
|
||||
"position": 1,
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
"dialogOpen": True,
|
||||
"status": "Latest checklist unavailable Try again.",
|
||||
"action": "Review changes",
|
||||
}
|
||||
|
||||
|
||||
def test_recovery_retains_failed_delivery_with_actionable_status():
|
||||
script = f"""
|
||||
const createRecovery = require({json.dumps(str(RECOVERY))});
|
||||
(async () => {{
|
||||
const element = () => ({{textContent:'', disabled:false}});
|
||||
const elements = {{
|
||||
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
|
||||
title:element(), destination:element(), reason:element(), progress:element(),
|
||||
action:element(), status:element(),
|
||||
}};
|
||||
const recovery = createRecovery({{
|
||||
getItems: () => [{{outbox_id:'retry', delivery_state:'waiting', title:'Post comment'}}],
|
||||
activate: async () => {{throw new Error('Network unavailable')}},
|
||||
elements,
|
||||
}});
|
||||
recovery.open();
|
||||
const result = await recovery.activate();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
result, disabled:elements.action.disabled, status:elements.status.textContent,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": {
|
||||
"count": 1,
|
||||
"current": {
|
||||
"id": "retry",
|
||||
"state": "waiting",
|
||||
"primary": "Retry now",
|
||||
"position": 1,
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
"disabled": False,
|
||||
"status": "Network unavailable Try again.",
|
||||
}
|
||||
|
||||
|
||||
def test_recovery_renders_delivery_context_and_hands_off_when_cleared():
|
||||
script = f"""
|
||||
const createRecovery = require({json.dumps(str(RECOVERY))});
|
||||
(async () => {{
|
||||
let items = [{{
|
||||
outbox_id:'close-7', status:'authorization', outbox_kind:'issue-close',
|
||||
title:'Close resolved incident', repository:'stackchain/ops',
|
||||
details:'#7', last_attempt_error:'Fresh authorization expired',
|
||||
}}];
|
||||
let completed = 0;
|
||||
const element = () => ({{textContent:'', hidden:false}});
|
||||
const elements = {{
|
||||
dialog: {{open:false, showModal(){{this.open=true}}, close(){{this.open=false}}}},
|
||||
title: element(), destination: element(), reason: element(), progress: element(),
|
||||
action: element(), status: element(),
|
||||
}};
|
||||
const recovery = createRecovery({{
|
||||
getItems: () => items,
|
||||
activate: async () => {{items = []; return true}},
|
||||
onComplete: () => {{completed += 1}},
|
||||
elements,
|
||||
}});
|
||||
const opened = recovery.open();
|
||||
const rendered = {{
|
||||
opened, dialogOpen:elements.dialog.open, title:elements.title.textContent,
|
||||
destination:elements.destination.textContent, reason:elements.reason.textContent,
|
||||
progress:elements.progress.textContent, action:elements.action.textContent,
|
||||
}};
|
||||
const cleared = await recovery.activate();
|
||||
process.stdout.write(JSON.stringify({{rendered, cleared, completed, dialogOpen:elements.dialog.open}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"rendered": {
|
||||
"opened": "opened",
|
||||
"dialogOpen": True,
|
||||
"title": "Close resolved incident",
|
||||
"destination": "stackchain/ops · #7",
|
||||
"reason": "Fresh authorization expired",
|
||||
"progress": "Delivery 1 of 1 · Authorization required",
|
||||
"action": "Authorize & close",
|
||||
},
|
||||
"cleared": {"count": 0, "current": None},
|
||||
"completed": 1,
|
||||
"dialogOpen": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_packages_phone_safe_guided_delivery_recovery():
|
||||
html = await dashboard()
|
||||
service_worker = (ROOT / "frontend" / "service-worker.js").read_text()
|
||||
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
|
||||
|
||||
assert '<dialog id="mobile-delivery-recovery"' in html
|
||||
assert 'aria-labelledby="mobile-delivery-recovery-title"' in html
|
||||
assert 'id="mobile-delivery-recovery-action"' in html
|
||||
assert '<script src="static/mobile-delivery-recovery.js"></script>' in html
|
||||
assert "const mobileDeliveryRecovery = createMobileDeliveryRecovery({" in html
|
||||
assert "activate: recoverMobileDelivery," in html
|
||||
assert "button.onclick=async()=>" in html
|
||||
assert "await (authored ? authoredOutbox : issueOutbox).retry(item.outbox_id, activeFlushLogin)" in html
|
||||
assert "(authored ? applyAuthoredOutboxResult : applyOutboxResult)(result)" in html
|
||||
assert "openDelivery: () => mobileDeliveryRecovery.open()" in html
|
||||
assert "mobileStartDay.completePhase('delivery')" in html
|
||||
assert "BASE + 'static/mobile-delivery-recovery.js'" in service_worker
|
||||
assert '"static/mobile-delivery-recovery.js"' in bundle
|
||||
assert ".mobile-delivery-recovery-panel" in html
|
||||
assert "min-height:48px" in html
|
||||
assert "env(safe-area-inset-bottom)" in html
|
||||
@@ -197,6 +197,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
root = MODULE.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||
storage_module = (root / "frontend" / "device-storage.js").read_text()
|
||||
worker = (root / "frontend" / "service-worker.js").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
|
||||
@@ -208,13 +209,21 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert 'id="device-setup-sheet"' in html
|
||||
assert 'aria-labelledby="device-setup-heading"' in html
|
||||
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push", "deadline"))
|
||||
assert 'id="device-storage-summary"' in html
|
||||
assert 'id="device-storage-detail"' in html
|
||||
assert 'id="clear-device-caches"' in html
|
||||
assert 'id="clear-private-device-data"' in html
|
||||
assert '<script src="static/private-device-data.js"></script>' in html
|
||||
assert '<script src="static/device-storage.js"></script>' in html
|
||||
assert 'id="device-setup-deadline-hour"' in html
|
||||
assert '<script src="static/mobile-device-setup.js"></script>' in html
|
||||
assert "createMobileDeviceSetup.mount({" in dashboard
|
||||
assert "createDeviceStorage.mount(document)" in dashboard
|
||||
assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v114" in worker
|
||||
assert "stackchain-dashboard-shell-v117" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-insights.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_mobile_insights_opens_as_addressable_modal_and_hides_home_chrome():
|
||||
script = f"""
|
||||
const createInsights = require({json.dumps(str(CONTROLLER))});
|
||||
const listeners = {{}};
|
||||
const element = () => ({{
|
||||
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
|
||||
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }},
|
||||
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
|
||||
focus() {{ this.focuses += 1; }},
|
||||
}});
|
||||
const root = element(); const launcher = element(); const closeButton = element();
|
||||
const dock = element(); const hud = element(); const home = element(); const header = element();
|
||||
const menu = {{open:true}};
|
||||
const location = {{pathname:'/dashboard/', search:'', hash:'#/my-work/today'}};
|
||||
const history = {{state:null, pushes:[], pushState(state,unused,url) {{
|
||||
this.state=state; this.pushes.push(url); location.hash=url.slice(url.indexOf('#'));
|
||||
}}, replaceState() {{}}, back() {{}}}};
|
||||
const controller = createInsights({{
|
||||
root, launcher, closeButton, dock, hud, backgrounds:[header,home], menu, history, location,
|
||||
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
|
||||
mediaQuery:{{matches:true, addEventListener() {{}}}},
|
||||
}});
|
||||
controller.start();
|
||||
launcher.listeners.click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
hash:location.hash, pushes:history.pushes, open:root.attributes['data-mobile-open'],
|
||||
role:root.attributes.role, modal:root.attributes['aria-modal'], rootInert:root.inert,
|
||||
homeInert:home.inert, headerInert:header.inert, dockHidden:dock.hidden,
|
||||
hudHidden:hud.attributes['data-overlay-hidden'], closeFocuses:closeButton.focuses,
|
||||
menuOpen:menu.open,
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"hash": "#/insights",
|
||||
"pushes": ["/dashboard/#/insights"],
|
||||
"open": "true",
|
||||
"role": "dialog",
|
||||
"modal": "true",
|
||||
"rootInert": False,
|
||||
"homeInert": True,
|
||||
"headerInert": True,
|
||||
"dockHidden": True,
|
||||
"hudHidden": "true",
|
||||
"closeFocuses": 1,
|
||||
"menuOpen": False,
|
||||
}
|
||||
|
||||
|
||||
def test_direct_insights_route_closes_locally_and_restores_launcher_focus():
|
||||
script = f"""
|
||||
const createInsights = require({json.dumps(str(CONTROLLER))});
|
||||
const listeners = {{}};
|
||||
const element = () => ({{
|
||||
hidden:false, inert:false, attributes:{{}}, focuses:0, listeners:{{}},
|
||||
setAttribute(name,value) {{ this.attributes[name] = String(value); }},
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }},
|
||||
addEventListener(name,fn) {{ this.listeners[name] = fn; }},
|
||||
focus() {{ this.focuses += 1; }},
|
||||
}});
|
||||
const root=element(), launcher=element(), closeButton=element(), dock=element(), hud=element(), home=element();
|
||||
const location={{pathname:'/dashboard/', search:'', hash:'#/insights'}};
|
||||
const history={{state:null, backs:0, replacements:[], pushState() {{}}, back() {{ this.backs += 1; }},
|
||||
replaceState(state,unused,url) {{ this.state=state; this.replacements.push(url); location.hash=url.slice(url.indexOf('#')); }}
|
||||
}};
|
||||
const controller=createInsights({{
|
||||
root,launcher,closeButton,dock,hud,backgrounds:[home],history,location,
|
||||
eventTarget:{{addEventListener(name,fn) {{ listeners[name]=fn; }}}},
|
||||
mediaQuery:{{matches:true,addEventListener() {{}}}},
|
||||
}});
|
||||
controller.start();
|
||||
closeButton.listeners.click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
current:controller.current(), hash:location.hash, backs:history.backs,
|
||||
replacements:history.replacements, rootInert:root.inert, homeInert:home.inert,
|
||||
dockHidden:dock.hidden, hudHidden:hud.attributes['data-overlay-hidden'] || null,
|
||||
launcherFocuses:launcher.focuses,
|
||||
}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"current": False,
|
||||
"hash": "#/my-work",
|
||||
"backs": 0,
|
||||
"replacements": ["/dashboard/#/my-work"],
|
||||
"rootInert": True,
|
||||
"homeInert": False,
|
||||
"dockHidden": False,
|
||||
"hudHidden": None,
|
||||
"launcherFocuses": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_browser_back_closes_insights_without_resetting_the_work_route():
|
||||
script = f"""
|
||||
const createInsights = require({json.dumps(str(CONTROLLER))});
|
||||
const listeners={{}};
|
||||
const element=()=>({{hidden:false,inert:false,attributes:{{}},focuses:0,listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{this.focuses+=1;}}}});
|
||||
const root=element(),launcher=element(),closeButton=element(),dock=element(),hud=element(),home=element();
|
||||
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work/update'}};
|
||||
const history={{state:null,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{}}}};
|
||||
const controller=createInsights({{root,launcher,closeButton,dock,hud,backgrounds:[home],history,location,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
|
||||
controller.start(); launcher.listeners.click();
|
||||
location.hash='#/my-work/update'; history.state=null; listeners.popstate?.({{state:null}});
|
||||
process.stdout.write(JSON.stringify({{current:controller.current(),hash:location.hash,launcherFocuses:launcher.focuses,homeInert:home.inert,dockHidden:dock.hidden}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"current": False,
|
||||
"hash": "#/my-work/update",
|
||||
"launcherFocuses": 1,
|
||||
"homeInert": False,
|
||||
"dockHidden": False,
|
||||
}
|
||||
|
||||
|
||||
def test_escape_requests_back_navigation_only_while_insights_is_open():
|
||||
script = f"""
|
||||
const createInsights=require({json.dumps(str(CONTROLLER))});
|
||||
const listeners={{}};
|
||||
const element=()=>({{hidden:false,inert:false,attributes:{{}},listeners:{{}},setAttribute(n,v){{this.attributes[n]=String(v);}},removeAttribute(n){{delete this.attributes[n];}},addEventListener(n,f){{this.listeners[n]=f;}},focus(){{}}}});
|
||||
const root=element(),launcher=element(),closeButton=element(),dock=element(),hud=element();
|
||||
const location={{pathname:'/dashboard/',search:'',hash:'#/my-work'}};
|
||||
const history={{state:null,backs:0,pushState(state,unused,url){{this.state=state;location.hash=url.slice(url.indexOf('#'));}},replaceState(){{}},back(){{this.backs+=1;}}}};
|
||||
const controller=createInsights({{root,launcher,closeButton,dock,hud,backgrounds:[],history,location,eventTarget:{{addEventListener(n,f){{listeners[n]=f;}}}},mediaQuery:{{matches:true,addEventListener(){{}}}}}});
|
||||
controller.start(); launcher.listeners.click();
|
||||
let prevented=0; listeners.keydown?.({{key:'Escape',preventDefault(){{prevented+=1;}}}}); listeners.keydown?.({{key:'Enter',preventDefault(){{prevented+=1;}}}});
|
||||
process.stdout.write(JSON.stringify({{backs:history.backs,prevented}}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {"backs": 1, "prevented": 1}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_home_progressively_discloses_secondary_panels_as_insights():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="open-insights"' in html
|
||||
assert 'id="insights-sheet"' in html
|
||||
assert 'id="close-insights"' in html
|
||||
assert html.index('id="my-work"') < html.index('id="insights-sheet"')
|
||||
assert html.index('id="insights-sheet"') < html.index('data-panel-key="context"')
|
||||
assert html.index('data-panel-key="markdown"') < html.index('</section>\n</main>')
|
||||
assert '<script src="static/mobile-insights.js"></script>' in html
|
||||
assert "createMobileInsights({" in html
|
||||
assert "mediaQuery: window.matchMedia('(max-width: 600px)')" in html
|
||||
assert ".insights-shell { display:contents; }" in html
|
||||
assert "#open-insights { display:none; }" in html
|
||||
assert '.insights-shell[data-mobile-open="true"]' in html
|
||||
assert "position:fixed; inset:0;" in html
|
||||
assert "overflow-x:hidden" in html
|
||||
assert ".insights-header button { min-height:44px;" in html
|
||||
|
||||
|
||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v117" in worker
|
||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||
@@ -116,6 +116,45 @@ process.stdout.write(JSON.stringify({{current,disconnected}}));
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_issue_navigation_distinguishes_taps_scrolls_and_keyboard_safe_restore():
|
||||
script = f"""
|
||||
const createNavigation = require({json.dumps(str(CONTROLLER))});
|
||||
class FakeElement {{
|
||||
constructor(name) {{ this.name=name; this.listeners={{}}; this.attributes={{}}; this.focuses=0; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name]=callback; }}
|
||||
removeEventListener() {{}}
|
||||
click() {{ this.listeners.click({{preventDefault() {{}}}}); }}
|
||||
setAttribute(name, value) {{ this.attributes[name]=value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
scrollIntoView() {{}}
|
||||
focus() {{ this.focuses += 1; }}
|
||||
}}
|
||||
const names=['overview','conversation','reply','actions'];
|
||||
const buttons=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
|
||||
const targets=Object.fromEntries(names.map(name => [name,new FakeElement(name)]));
|
||||
const changes=[];
|
||||
let observer;
|
||||
const navigation=createNavigation({{
|
||||
buttons, targets,
|
||||
onSectionChange:(name, options) => changes.push([name, options.replace]),
|
||||
observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }},
|
||||
}});
|
||||
navigation.start();
|
||||
buttons.reply.click();
|
||||
observer([{{target:targets.actions,isIntersecting:true,intersectionRatio:1}}]);
|
||||
navigation.navigate('reply', {{focus:false}});
|
||||
process.stdout.write(JSON.stringify({{changes,replyFocuses:targets.reply.focuses,current:buttons.reply.attributes['aria-current']}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"changes": [["reply", False], ["actions", True]],
|
||||
"replyFocuses": 1,
|
||||
"current": "location",
|
||||
}
|
||||
|
||||
|
||||
def test_issue_sheet_ships_a_mobile_only_safe_area_navigation_rail():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from src.frontend_bundle import build_frontend
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-pull-refresh.js"
|
||||
|
||||
|
||||
def run_scenario(source: str) -> dict:
|
||||
script = f"""
|
||||
const createPullRefresh = require({json.dumps(str(CONTROLLER))});
|
||||
class FakeElement {{
|
||||
constructor() {{
|
||||
this.listeners = {{}};
|
||||
this.dataset = {{}};
|
||||
this.hidden = true;
|
||||
this.textContent = '';
|
||||
}}
|
||||
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||
removeEventListener(name) {{ delete this.listeners[name]; }}
|
||||
setPointerCapture() {{}}
|
||||
releasePointerCapture() {{}}
|
||||
}}
|
||||
const surface = new FakeElement();
|
||||
const indicator = new FakeElement();
|
||||
const plain = {{closest: () => null, parentElement: null, scrollWidth: 100, clientWidth: 100}};
|
||||
const event = (x, y, target=plain, id=1) => ({{
|
||||
pointerId:id, pointerType:'touch', clientX:x, clientY:y, target,
|
||||
preventDefault() {{ this.prevented = true; }},
|
||||
}});
|
||||
{source}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_mobile_pull_refresh_runs_one_refresh_after_vertical_threshold_and_announces_success():
|
||||
result = run_scenario("""
|
||||
let calls = 0;
|
||||
const controller = createPullRefresh({
|
||||
surface, indicator, threshold:64,
|
||||
refresh: async () => { calls += 1; },
|
||||
isMobile: () => true,
|
||||
getScrollY: () => 0,
|
||||
isBlocked: () => false,
|
||||
});
|
||||
controller.start();
|
||||
surface.listeners.pointerdown(event(100, 10));
|
||||
surface.listeners.pointermove(event(102, 90));
|
||||
const ready = {hidden:indicator.hidden, state:indicator.dataset.state, text:indicator.textContent};
|
||||
const completion = surface.listeners.pointerup(event(102, 90));
|
||||
Promise.resolve(completion).then(() => process.stdout.write(JSON.stringify({
|
||||
calls, ready,
|
||||
final:{hidden:indicator.hidden, state:indicator.dataset.state, text:indicator.textContent},
|
||||
})));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 1,
|
||||
"ready": {"hidden": False, "state": "ready", "text": "Release to refresh"},
|
||||
"final": {"hidden": False, "state": "success", "text": "My Work is up to date"},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_pull_refresh_ignores_ineligible_gestures_and_short_pulls():
|
||||
result = run_scenario("""
|
||||
let calls = 0;
|
||||
let mobile = true;
|
||||
let scrollY = 0;
|
||||
let blocked = false;
|
||||
const controller = createPullRefresh({
|
||||
surface, indicator, threshold:64,
|
||||
refresh: async () => { calls += 1; },
|
||||
isMobile: () => mobile,
|
||||
getScrollY: () => scrollY,
|
||||
isBlocked: () => blocked,
|
||||
});
|
||||
controller.start();
|
||||
async function drag(fromX, fromY, toX, toY, target=plain) {
|
||||
surface.listeners.pointerdown(event(fromX, fromY, target));
|
||||
surface.listeners.pointermove(event(toX, toY, target));
|
||||
await surface.listeners.pointerup(event(toX, toY, target));
|
||||
}
|
||||
(async () => {
|
||||
await drag(100, 10, 102, 50);
|
||||
await drag(100, 10, 180, 30);
|
||||
mobile = false; await drag(100, 10, 100, 100);
|
||||
mobile = true; scrollY = 1; await drag(100, 10, 100, 100);
|
||||
scrollY = 0; blocked = true; await drag(100, 10, 100, 100);
|
||||
blocked = false;
|
||||
const interactive = {closest: selector => selector ? interactive : null, parentElement:null, scrollWidth:100, clientWidth:100};
|
||||
await drag(100, 10, 100, 100, interactive);
|
||||
const scroller = {closest: () => null, parentElement:null, scrollWidth:300, clientWidth:100};
|
||||
await drag(100, 10, 100, 100, scroller);
|
||||
process.stdout.write(JSON.stringify({calls}));
|
||||
})();
|
||||
""")
|
||||
|
||||
assert result == {"calls": 0}
|
||||
|
||||
|
||||
def test_mobile_pull_refresh_stays_single_flight_and_keeps_refreshing_status():
|
||||
result = run_scenario("""
|
||||
let calls = 0;
|
||||
let finish;
|
||||
const pending = new Promise(resolve => { finish = resolve; });
|
||||
const controller = createPullRefresh({
|
||||
surface, indicator, threshold:64,
|
||||
refresh: () => { calls += 1; return pending; },
|
||||
isMobile: () => true,
|
||||
getScrollY: () => 0,
|
||||
isBlocked: () => false,
|
||||
});
|
||||
controller.start();
|
||||
function pull(id) {
|
||||
surface.listeners.pointerdown(event(100, 10, plain, id));
|
||||
surface.listeners.pointermove(event(100, 90, plain, id));
|
||||
return surface.listeners.pointerup(event(100, 90, plain, id));
|
||||
}
|
||||
const first = pull(1);
|
||||
Promise.resolve().then(async () => {
|
||||
const second = pull(2);
|
||||
const during = {calls, state:indicator.dataset.state, text:indicator.textContent};
|
||||
finish();
|
||||
await first;
|
||||
await second;
|
||||
process.stdout.write(JSON.stringify({calls, during, final:indicator.dataset.state}));
|
||||
});
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 1,
|
||||
"during": {"calls": 1, "state": "refreshing", "text": "Refreshing My Work…"},
|
||||
"final": "success",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_pull_refresh_announces_failure_and_allows_a_retry():
|
||||
result = run_scenario("""
|
||||
let calls = 0;
|
||||
const controller = createPullRefresh({
|
||||
surface, indicator, threshold:64,
|
||||
refresh: async () => { calls += 1; if (calls === 1) throw new Error('offline secret'); },
|
||||
isMobile: () => true,
|
||||
getScrollY: () => 0,
|
||||
isBlocked: () => false,
|
||||
});
|
||||
controller.start();
|
||||
async function pull(id) {
|
||||
surface.listeners.pointerdown(event(100, 10, plain, id));
|
||||
surface.listeners.pointermove(event(100, 90, plain, id));
|
||||
try { await surface.listeners.pointerup(event(100, 90, plain, id)); } catch (_) {}
|
||||
}
|
||||
(async () => {
|
||||
await pull(1);
|
||||
const failed = {state:indicator.dataset.state, text:indicator.textContent};
|
||||
await pull(2);
|
||||
process.stdout.write(JSON.stringify({calls, failed, final:indicator.dataset.state}));
|
||||
})();
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"calls": 2,
|
||||
"failed": {"state": "error", "text": "Could not refresh · pull to retry"},
|
||||
"final": "success",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_pull_refresh_is_wired_to_live_refresh_and_packaged_offline():
|
||||
html = await dashboard()
|
||||
frontend = CONTROLLER.parent
|
||||
build = build_frontend(frontend)
|
||||
today_bundle = build.feature_bundles["today-timer"].runtime_bytes.decode()
|
||||
|
||||
assert 'id="mobile-pull-refresh"' in html
|
||||
assert 'role="status"' in html
|
||||
assert html.index('static/mobile-pull-refresh.js') < html.index('static/dashboard.js')
|
||||
assert "createMobilePullRefresh" in html
|
||||
assert "contextPoller.refresh({ force: true })" in html
|
||||
assert "createMobilePullRefresh" in today_bundle
|
||||
assert "liveDataStatus" in today_bundle
|
||||
assert build.feature_bundles["today-timer"].runtime_name in build.service_worker_source
|
||||
@@ -116,6 +116,38 @@ process.stdout.write(JSON.stringify({{current,rootMatches:observedRoot === panel
|
||||
}
|
||||
|
||||
|
||||
def test_review_navigation_routes_taps_and_scrolls_without_focusing_on_restore():
|
||||
script = f"""
|
||||
const createNavigation=require({json.dumps(str(CONTROLLER))});
|
||||
const element=name => ({{name,listeners:{{}},attributes:{{}},focuses:0,
|
||||
addEventListener(key,fn) {{ this.listeners[key]=fn; }}, removeEventListener() {{}},
|
||||
setAttribute(key,value) {{ this.attributes[key]=value; }}, removeAttribute(key) {{ delete this.attributes[key]; }},
|
||||
scrollIntoView() {{}}, focus() {{ this.focuses += 1; }},
|
||||
}});
|
||||
const names=['overview','files','feedback','history'];
|
||||
const buttons=Object.fromEntries(names.map(name => [name,element(name)]));
|
||||
const targets=Object.fromEntries(names.map(name => [name,element(name)]));
|
||||
const composer=element('composer'); const changes=[]; let observer;
|
||||
const navigation=createNavigation({{buttons,targets,summaryComposer:composer,
|
||||
onSectionChange:(name,options)=>changes.push([name,options.replace]),
|
||||
observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }},
|
||||
}});
|
||||
navigation.start();
|
||||
buttons.feedback.listeners.click({{preventDefault() {{}}}});
|
||||
observer([{{target:targets.history,isIntersecting:true,intersectionRatio:1}}]);
|
||||
navigation.navigate('feedback',{{focus:false}});
|
||||
process.stdout.write(JSON.stringify({{changes,focuses:composer.focuses,current:buttons.feedback.attributes['aria-current']}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"changes": [["feedback", False], ["history", True]],
|
||||
"focuses": 1,
|
||||
"current": "location",
|
||||
}
|
||||
|
||||
|
||||
def test_review_sheet_wires_a_mobile_safe_area_navigation_rail_and_offline_asset():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
@@ -72,6 +72,44 @@ process.stdout.write(JSON.stringify({{first, second, ready, opened}}));
|
||||
}
|
||||
|
||||
|
||||
def test_prepare_today_puts_actionable_delivery_recovery_before_other_work():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
let counts = {{delivery:2, agenda:1, attention:0, update:0, filed:0, today:4}};
|
||||
const opened = [];
|
||||
const handoffs = [];
|
||||
const saved = new Map();
|
||||
const controller = createStartDay({{
|
||||
getCounts: () => counts,
|
||||
getLogin: () => 'timmy',
|
||||
getDay: () => '2026-08-17',
|
||||
storage: {{getItem:key=>saved.get(key)||null,setItem:(key,value)=>saved.set(key,value),removeItem:key=>saved.delete(key)}},
|
||||
openQueue: name => opened.push(name),
|
||||
onHandoff: state => handoffs.push(state.next),
|
||||
}});
|
||||
const blocked = controller.briefing();
|
||||
controller.startNext();
|
||||
counts = {{delivery:0, agenda:1, attention:0, update:0, filed:0, today:4}};
|
||||
const handed = controller.reconcile({{authoritative:true}});
|
||||
process.stdout.write(JSON.stringify({{blocked, handed, opened, handoffs}}));
|
||||
"""
|
||||
|
||||
output = run_node(script)
|
||||
assert output["blocked"] == {
|
||||
"total": 3,
|
||||
"next": "delivery",
|
||||
"label": "Review Delivery",
|
||||
"summary": "2 deliveries need action before Today · 1 other item · 4 planned",
|
||||
"phases": [
|
||||
{"name": "delivery", "label": "Delivery recovery", "count": 2},
|
||||
{"name": "agenda", "label": "Agenda", "count": 1},
|
||||
],
|
||||
}
|
||||
assert output["handed"] is True
|
||||
assert output["opened"] == ["delivery"]
|
||||
assert output["handoffs"] == ["agenda"]
|
||||
|
||||
|
||||
def test_start_day_view_renders_refreshed_phases_and_launches_primary_action():
|
||||
script = f"""
|
||||
const createStartDay = require({json.dumps(str(START_DAY))});
|
||||
@@ -273,7 +311,8 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||
assert "mobileStartDay.completePhase('agenda')" in html
|
||||
assert "mobileStartDay.completePhase('update')" in html
|
||||
assert "mobileStartDay.completePhase('filed')" in html
|
||||
assert "mobileStartDay.reconcile({authoritative:authoritativeMyWorkRefresh})" in html
|
||||
assert "authoritative:authoritativeMyWorkRefresh" in html
|
||||
assert "authoritativePhases:['delivery']" in html
|
||||
assert "mobileStartDay.finish()" in html
|
||||
assert "openQueue: name =>" in html
|
||||
assert "if (sheet.open) sheet.close();" in html
|
||||
@@ -283,4 +322,4 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||
assert "stackchain-dashboard-shell-v114" in service_worker
|
||||
assert "stackchain-dashboard-shell-v117" in service_worker
|
||||
@@ -85,6 +85,30 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_work_entry_plans_eligible_work_before_find_fallback():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
const calls = [];
|
||||
const entry = createEntry({{
|
||||
isTodayActive: () => false,
|
||||
isTodayResumable: () => false,
|
||||
getTodayCount: () => 0,
|
||||
getEligibleCount: () => 2,
|
||||
queueLauncher: {{recommend: () => ({{name:'find'}}), open: () => {{}}}},
|
||||
continueToday: () => {{}},
|
||||
resumeToday: () => {{}},
|
||||
startToday: () => {{}},
|
||||
planToday: () => calls.push('plan'),
|
||||
findWork: () => calls.push('find'),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{mode:entry.open(), calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"mode": "plan", "calls": ["plan"]}
|
||||
|
||||
|
||||
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
@@ -331,7 +355,7 @@ sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
|
||||
const close = new FakeElement();
|
||||
const badge = new FakeElement();
|
||||
const deadline = new FakeElement();
|
||||
const rows = Object.fromEntries(['today','agenda','attention','update','filed','later','draft','recaps'].map(name => [name, new FakeElement()]));
|
||||
const rows = Object.fromEntries(['today','agenda','delivery','attention','update','filed','later','draft','recaps'].map(name => [name, new FakeElement()]));
|
||||
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
|
||||
const selected = [];
|
||||
const utilities = [];
|
||||
@@ -342,7 +366,7 @@ const dock = createDock({{
|
||||
observe() {{}},
|
||||
}});
|
||||
dock.start();
|
||||
dock.updateQueues({{today:2, agenda:5, attention:1, update:5, filed:2, later:3, draft:4}});
|
||||
dock.updateQueues({{today:2, agenda:5, delivery:1, attention:1, update:5, filed:2, later:3, draft:4}});
|
||||
queues.click();
|
||||
const opened = sheet.open;
|
||||
rows.update.click();
|
||||
@@ -387,9 +411,9 @@ process.stdout.write(JSON.stringify({{
|
||||
"utilities": ["recaps:trigger"],
|
||||
"badge": "0 active",
|
||||
"populated": {
|
||||
"badge": "6 active",
|
||||
"badge": "7 active",
|
||||
"badgeHidden": False,
|
||||
"badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 6 active queues",
|
||||
"badgeLabel": "Queues: Today 2, Agenda 5 due, Delivery 1, Attention 1, Updates 5, Filed 2, Later 3, Drafts 4; 7 active queues",
|
||||
"deadline": "5 due",
|
||||
"deadlineHidden": False,
|
||||
"agendaDue": "true",
|
||||
@@ -398,13 +422,13 @@ process.stdout.write(JSON.stringify({{
|
||||
"badge": "1 active",
|
||||
"badgeHidden": False,
|
||||
"deadlineHidden": True,
|
||||
"badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
|
||||
"badgeLabel": "Queues: Today 0, Agenda 0 due, Delivery 0, Attention 0, Updates 7, Filed 0, Later 0, Drafts 0; 1 active queue",
|
||||
},
|
||||
"clearedBadgeHidden": True,
|
||||
"clearedDeadlineHidden": True,
|
||||
"clearedAgendaDue": None,
|
||||
"clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines",
|
||||
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"},
|
||||
"counts": {"today": "0", "agenda": "0", "delivery": "0", "attention": "0", "update": "0", "filed": "0", "later": "0", "draft": "0", "recaps": "0"},
|
||||
"updateLabel": "Updates, 0 unread conversations",
|
||||
"queueFocuses": 3,
|
||||
"columnState": None,
|
||||
@@ -461,6 +485,82 @@ process.stdout.write(JSON.stringify({{result, calls}}));
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_uses_dedicated_delivery_recovery_flow():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
const launcher = createLauncher({{
|
||||
openDelivery: () => {{ calls.push('delivery-recovery'); return 'opened'; }},
|
||||
selectFilter: name => calls.push('generic-filter:' + name),
|
||||
firstAction: () => {{ throw new Error('generic card launch must not run'); }},
|
||||
}});
|
||||
const result = launcher.open('delivery');
|
||||
process.stdout.write(JSON.stringify({{result, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"result": "opened", "calls": ["delivery-recovery"]}
|
||||
|
||||
|
||||
def test_mobile_work_prioritizes_and_revalidates_actionable_delivery_recovery():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
let counts = {{delivery:2, attention:3, today:1, update:4}};
|
||||
const launcher = createLauncher({{
|
||||
getCounts: () => counts,
|
||||
openDelivery: () => {{ calls.push('delivery-recovery'); return 'opened-delivery'; }},
|
||||
openUpdates: () => {{ calls.push('updates'); return 'opened-updates'; }},
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: () => null,
|
||||
announce: () => {{}},
|
||||
openFindWork: () => calls.push('find'),
|
||||
}});
|
||||
const delivery = launcher.recommend();
|
||||
const opened = launcher.continueWork();
|
||||
counts = {{delivery:0, attention:0, today:0, update:4}};
|
||||
const afterRecovery = launcher.recommend();
|
||||
process.stdout.write(JSON.stringify({{delivery, opened, afterRecovery, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"delivery": {"name": "delivery", "count": 2, "label": "Recover Delivery (2)"},
|
||||
"opened": "opened-delivery",
|
||||
"afterRecovery": {"name": "update", "count": 4, "label": "Resume Updates (4)"},
|
||||
"calls": ["delivery-recovery"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_work_continues_into_filed_follow_up_before_later_work():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
let counts = {{attention:0, today:0, update:0, agenda:0, filed:2, later:4, draft:3}};
|
||||
const launcher = createLauncher({{
|
||||
getCounts: () => counts,
|
||||
openFiled: () => {{ calls.push('filed-follow-up'); return 'opened-filed'; }},
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: () => null,
|
||||
announce: () => {{}},
|
||||
openFindWork: () => calls.push('find'),
|
||||
}});
|
||||
const recommendation = launcher.recommend();
|
||||
const opened = launcher.continueWork();
|
||||
process.stdout.write(JSON.stringify({{recommendation, opened, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"recommendation": {"name": "filed", "count": 2, "label": "Review Filed (2)"},
|
||||
"opened": "opened-filed",
|
||||
"calls": ["filed-follow-up"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
@@ -531,7 +631,7 @@ const work = {{attributes: {{}}, setAttribute(name, value) {{ this.attributes[na
|
||||
const workLabel = {{textContent:''}};
|
||||
const dock = createDock({{nav:{{}}, buttons:{{work}}, workLabel}});
|
||||
const labels = {{}};
|
||||
for (const mode of ['attention','update','agenda','later','draft']) {{
|
||||
for (const mode of ['delivery','attention','update','agenda','filed','later','draft']) {{
|
||||
dock.updateWork(mode);
|
||||
labels[mode] = [workLabel.textContent, work.attributes['aria-label']];
|
||||
}}
|
||||
@@ -541,9 +641,11 @@ process.stdout.write(JSON.stringify(labels));
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"delivery": ["Delivery", "Open Delivery"],
|
||||
"attention": ["Attention", "Open Attention"],
|
||||
"update": ["Updates", "Resume Updates"],
|
||||
"agenda": ["Agenda", "Open Agenda"],
|
||||
"filed": ["Filed", "Open Filed"],
|
||||
"later": ["Later", "Open Later"],
|
||||
"draft": ["Drafts", "Open Drafts"],
|
||||
}
|
||||
@@ -576,6 +678,9 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
assert "counts.agenda = agendaItems.length;" in html
|
||||
assert 'data-agenda-group' in html
|
||||
assert 'data-mobile-queue="attention"' in html
|
||||
assert 'data-mobile-queue="delivery"' in html
|
||||
assert '<strong>Delivery</strong><small>Needs recovery</small>' in html
|
||||
assert 'data-mobile-queue-count="delivery"' in html
|
||||
assert 'data-mobile-queue="update"' in html
|
||||
assert '<strong>Updates</strong><small>Unread conversations</small>' in html
|
||||
assert 'data-mobile-queue="filed"' in html
|
||||
|
||||
@@ -123,6 +123,38 @@ process.stdout.write(JSON.stringify({{current,disconnected}}));
|
||||
}
|
||||
|
||||
|
||||
def test_update_navigation_routes_taps_and_scrolls_without_focusing_on_restore():
|
||||
script = f"""
|
||||
const createNavigation=require({json.dumps(str(CONTROLLER))});
|
||||
const element=name => ({{name,listeners:{{}},attributes:{{}},focuses:0,open:false,
|
||||
addEventListener(key,fn) {{ this.listeners[key]=fn; }}, removeEventListener() {{}},
|
||||
setAttribute(key,value) {{ this.attributes[key]=value; }}, removeAttribute(key) {{ delete this.attributes[key]; }},
|
||||
scrollIntoView() {{}}, focus() {{ this.focuses += 1; }},
|
||||
}});
|
||||
const names=['activity','conversation','context','reply'];
|
||||
const buttons=Object.fromEntries(names.map(name => [name,element(name)]));
|
||||
const targets=Object.fromEntries(['conversation','context','reply'].map(name => [name,element(name)]));
|
||||
const composer=element('composer'); const changes=[]; let observer;
|
||||
const navigation=createNavigation({{buttons,targets,replyComposer:composer,jumpToNewActivity() {{}},
|
||||
onSectionChange:(name,options)=>changes.push([name,options.replace]),
|
||||
observe(handler) {{ observer=handler; return {{disconnect() {{}}}}; }},
|
||||
}});
|
||||
navigation.start();
|
||||
buttons.reply.listeners.click({{preventDefault() {{}}}});
|
||||
observer([{{target:targets.context,isIntersecting:true,intersectionRatio:1}}]);
|
||||
navigation.navigate('reply',{{focus:false}});
|
||||
process.stdout.write(JSON.stringify({{changes,focuses:composer.focuses,current:buttons.reply.attributes['aria-current']}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"changes": [["reply", False], ["context", True]],
|
||||
"focuses": 1,
|
||||
"current": "location",
|
||||
}
|
||||
|
||||
|
||||
def test_update_sheet_wires_a_mobile_only_safe_area_navigation_rail():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
+81
-1
@@ -1477,6 +1477,68 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
|
||||
assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"
|
||||
|
||||
|
||||
def test_work_route_sections_are_validated_back_safe_and_do_not_reopen_the_item():
|
||||
script = f"""
|
||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||
const listeners = {{}};
|
||||
const location = {{hash:'#/my-work/issue/stackchain/api/17'}};
|
||||
const calls = [];
|
||||
const stack = [location.hash];
|
||||
let cursor = 0;
|
||||
const history = {{
|
||||
pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; calls.push(['push', hash]); }},
|
||||
replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; calls.push(['replace', hash]); }},
|
||||
back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
|
||||
}};
|
||||
const controller = routes.createController({{
|
||||
location, history,
|
||||
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
|
||||
onOpen: item => calls.push(['open', item.number, item.section || null]),
|
||||
onSection: (section, options) => calls.push(['section', section, options.restore]),
|
||||
onClose() {{}}, onInvalid() {{}},
|
||||
}});
|
||||
controller.start();
|
||||
controller.setItems([{{kind:'issue', repository:'stackchain/api', number:17}}]);
|
||||
controller.section('reply');
|
||||
controller.section('actions', {{replace:true}});
|
||||
history.back();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls,
|
||||
hash:location.hash,
|
||||
valid:[
|
||||
routes.parse('#/my-work/issue/stackchain/api/17/reply'),
|
||||
routes.parse('#/my-work/update/42/context'),
|
||||
],
|
||||
invalid:[
|
||||
routes.parse('#/my-work/issue/stackchain/api/17/files'),
|
||||
routes.parse('#/my-work/update/42/overview'),
|
||||
routes.parse('#/my-work/review/stackchain/api/17/feedback/extra'),
|
||||
],
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"calls": [
|
||||
["open", 17, None],
|
||||
["push", "#/my-work/issue/stackchain/api/17/reply"],
|
||||
["section", "reply", False],
|
||||
["replace", "#/my-work/issue/stackchain/api/17/actions"],
|
||||
["section", "actions", False],
|
||||
["section", "overview", True],
|
||||
],
|
||||
"hash": "#/my-work/issue/stackchain/api/17",
|
||||
"valid": [
|
||||
{"kind": "issue", "repository": "stackchain/api", "number": 17, "section": "reply"},
|
||||
{"kind": "update", "notification_id": 42, "section": "context"},
|
||||
],
|
||||
"invalid": [None, None, None],
|
||||
}
|
||||
|
||||
|
||||
def test_updates_inbox_route_survives_hydration_and_detail_back_navigation():
|
||||
script = f"""
|
||||
const routes = require({json.dumps(str(WORK_ROUTE))});
|
||||
@@ -1690,7 +1752,7 @@ const calls = [];
|
||||
async def test_dashboard_opens_delegated_filings_with_follow_up_only_capabilities():
|
||||
html = await dashboard()
|
||||
|
||||
assert "else if (item.kind === 'issue' || item.kind === 'filed') openIssueSheet(item, issueTrigger);" in html
|
||||
assert "else if (item.kind === 'issue' || item.kind === 'filed') await openIssueSheet(item, issueTrigger);" in html
|
||||
assert "const readOnly = issueController.readOnly(item);" in html
|
||||
assert "qs('#issue-sheet').classList.toggle('read-only', readOnly);" in html
|
||||
assert "#issue-sheet.read-only .issue-comment-composer" not in html
|
||||
@@ -1730,6 +1792,24 @@ async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share
|
||||
assert "openDeliveryReceiptRoute" not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_mobile_section_routes_to_each_detail_navigation():
|
||||
html = await dashboard()
|
||||
|
||||
assert html.count(
|
||||
"onSectionChange:(section, options) => workRoute.section(section, options)"
|
||||
) == 4
|
||||
assert "function navigateWorkSection(kind, section)" in html
|
||||
assert "mobileIssueDetailNavigation.navigate(section, { focus:false })" in html
|
||||
assert "mobilePullDetailNavigation.navigate(section, { focus:false })" in html
|
||||
assert "mobileUpdateDetailNavigation.navigate(section, { focus:false })" in html
|
||||
assert "mobileReviewDetailNavigation.navigate(section, { focus:false })" in html
|
||||
assert "onSection: (section, options) => {" in html
|
||||
assert "if (options.restore) navigateWorkSection(route?.kind, section);" in html
|
||||
assert "await openRoutedWorkSection(item);" in html
|
||||
assert "navigateWorkSection(item.kind, item.section);" in html
|
||||
|
||||
|
||||
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
|
||||
@@ -410,7 +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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" 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,79 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "private-data-inventory.js"
|
||||
|
||||
|
||||
def run_inventory(databases: dict[str, list[int]], registered: list[str]) -> dict:
|
||||
script = r"""
|
||||
const createInspector = require(__MODULE__);
|
||||
const databases = __DATABASES__;
|
||||
const indexedDB = {
|
||||
async databases() { return Object.keys(databases).map(name => ({name})); },
|
||||
open(name) {
|
||||
const request = {};
|
||||
queueMicrotask(() => {
|
||||
const stores = databases[name];
|
||||
const db = {
|
||||
objectStoreNames: stores.map((_records, index) => `store-${index}`),
|
||||
transaction(names) {
|
||||
return {
|
||||
objectStore(storeName) {
|
||||
return {
|
||||
count() {
|
||||
const countRequest = {};
|
||||
const index = Number(storeName.split('-')[1]);
|
||||
queueMicrotask(() => {
|
||||
countRequest.result = stores[index];
|
||||
countRequest.onsuccess?.();
|
||||
});
|
||||
return countRequest;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
request.result = db;
|
||||
request.onsuccess?.();
|
||||
});
|
||||
return request;
|
||||
},
|
||||
};
|
||||
createInspector(indexedDB)(__REGISTERED__)
|
||||
.then(result => process.stdout.write(JSON.stringify(result)))
|
||||
.catch(error => { console.error(error); process.exit(1); });
|
||||
"""
|
||||
script = (
|
||||
script.replace("__MODULE__", json.dumps(str(MODULE)))
|
||||
.replace("__DATABASES__", json.dumps(databases))
|
||||
.replace("__REGISTERED__", json.dumps(registered))
|
||||
)
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_inventory_counts_records_without_creating_absent_registered_databases():
|
||||
result = run_inventory(
|
||||
{"private-a": [2, 1], "other-app": [99]},
|
||||
["private-a", "private-empty", "private-missing"],
|
||||
)
|
||||
|
||||
assert result == {"recordCount": 3, "unavailable": False}
|
||||
|
||||
|
||||
def test_inventory_is_conservatively_unavailable_when_enumeration_is_unsupported():
|
||||
script = f"""
|
||||
const createInspector = require({json.dumps(str(MODULE))});
|
||||
createInspector({{}})(['private-a'])
|
||||
.then(result => process.stdout.write(JSON.stringify(result)))
|
||||
.catch(error => {{ console.error(error.message); process.exit(1); }});
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {"recordCount": 0, "unavailable": True}
|
||||
@@ -44,3 +44,12 @@ def test_every_private_data_purge_context_consumes_the_shared_registry():
|
||||
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
assert html.index('static/private-data-registry.js') < html.index('static/session.js')
|
||||
|
||||
|
||||
def test_private_inventory_loads_after_registry_and_before_storage_controller():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
registry = html.index('static/private-data-registry.js')
|
||||
inventory = html.index('static/private-data-inventory.js')
|
||||
controller = html.index('static/device-storage.js')
|
||||
|
||||
assert registry < inventory < controller
|
||||
@@ -9,12 +9,12 @@ PRIVATE_DATA_JS = Path(__file__).resolve().parents[1] / "frontend" / "private-de
|
||||
def test_private_device_data_purger_waits_for_owned_outbox_and_cache_deletion():
|
||||
harness = f"""
|
||||
const createPrivateDeviceDataPurger = require({json.dumps(str(PRIVATE_DATA_JS))});
|
||||
const state = {{removed:[], databases:[], caches:[], workerMessages:[], complete:false}};
|
||||
const state = {{removed:[], databases:[], caches:[], workerMessages:[], order:[], complete:false}};
|
||||
const storage = {{
|
||||
values:new Map([['stackchain.draft','private'],['other.preference','keep']]),
|
||||
get length(){{return this.values.size;}},
|
||||
key(index){{return Array.from(this.values.keys())[index] || null;}},
|
||||
removeItem(key){{state.removed.push(key);this.values.delete(key);}},
|
||||
removeItem(key){{state.order.push('storage');state.removed.push(key);this.values.delete(key);}},
|
||||
}};
|
||||
const clear = createPrivateDeviceDataPurger({{
|
||||
localStorage:storage, sessionStorage:storage,
|
||||
@@ -25,7 +25,7 @@ const clear = createPrivateDeviceDataPurger({{
|
||||
return request;
|
||||
}}}},
|
||||
caches:{{keys:async()=>['stackchain-dashboard-shell-v37','other-app'],delete:async key=>state.caches.push(key)}},
|
||||
serviceWorker:{{ready:Promise.resolve({{active:{{postMessage:(message,ports)=>{{state.workerMessages.push(message);ports[0].postMessage({{ok:true}});}}}}}})}},
|
||||
serviceWorker:{{ready:Promise.resolve({{active:{{postMessage:(message,ports)=>{{state.order.push('worker');state.workerMessages.push(message);ports[0].postMessage({{ok:true}});}}}}}})}},
|
||||
MessageChannel:class{{constructor(){{
|
||||
const first={{onmessage:null,postMessage:data=>queueMicrotask(()=>second.onmessage?.({{data}}))}};
|
||||
const second={{onmessage:null,postMessage:data=>queueMicrotask(()=>first.onmessage?.({{data}}))}};
|
||||
@@ -53,4 +53,5 @@ const clear = createPrivateDeviceDataPurger({{
|
||||
]
|
||||
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
|
||||
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
assert state["order"][0] == "worker"
|
||||
assert state["complete"] is True
|
||||
@@ -41,6 +41,36 @@ reconnect().then(result => process.stdout.write(JSON.stringify({{result, calls}}
|
||||
}
|
||||
|
||||
|
||||
def test_concurrent_reconnect_requests_queue_one_follow_up_flush():
|
||||
result = run_node(
|
||||
f"""
|
||||
const createReconnectOutboxes = require({json.dumps(str(RECONNECT))});
|
||||
(async () => {{
|
||||
let releaseFirst; let issueCalls=0; let active=0; let maxActive=0;
|
||||
const reconnect = createReconnectOutboxes({{
|
||||
refresh: async () => ({{context:{{user:{{login:'timmy'}}}}}}),
|
||||
restoreIdentity: () => {{}},
|
||||
flushIssue: async () => {{
|
||||
issueCalls++; active++; maxActive=Math.max(maxActive,active);
|
||||
if (issueCalls === 1) await new Promise(resolve => {{ releaseFirst=resolve; }});
|
||||
active--;
|
||||
}},
|
||||
flushAuthored: async () => {{}},
|
||||
flushNotificationReads: async () => {{}},
|
||||
}});
|
||||
const first=reconnect();
|
||||
while (!releaseFirst) await new Promise(resolve=>setTimeout(resolve,0));
|
||||
const second=reconnect();
|
||||
releaseFirst();
|
||||
await Promise.all([first,second]);
|
||||
process.stdout.write(JSON.stringify({{issueCalls,maxActive}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"issueCalls": 2, "maxActive": 1}
|
||||
|
||||
|
||||
def test_dashboard_uses_reconnect_flush_after_live_identity_refresh():
|
||||
index = (ROOT / "frontend" / "index.html").read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
@@ -48,4 +78,14 @@ def test_dashboard_uses_reconnect_flush_after_live_identity_refresh():
|
||||
assert '<script src="static/reconnect-outboxes.js"></script>' in index
|
||||
assert "const reconnectOutboxes = createReconnectOutboxes({" in dashboard
|
||||
assert "restoreIdentity: login => { activeFlushLogin = login; confirmedOwnerLogin = login; }" in dashboard
|
||||
assert "window.addEventListener('online', reconnectOutboxes);" in dashboard
|
||||
assert "window.addEventListener('online', reconnectAfterOnline);" in dashboard
|
||||
assert "setTimeout(reconnectOutboxes, 500)" in dashboard
|
||||
|
||||
|
||||
def test_dashboard_registers_reconnect_before_offline_startup_can_wait():
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
|
||||
listener = dashboard.index("window.addEventListener('online', reconnectAfterOnline);")
|
||||
offline_startup = dashboard.index("await updateOfflineWorkControls();")
|
||||
|
||||
assert listener < offline_startup
|
||||
@@ -83,6 +83,181 @@ const boundary={{
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
title, detail = json.loads(completed.stdout)
|
||||
title, detail, action = json.loads(completed.stdout)
|
||||
assert title == "Passkey counter anomaly"
|
||||
assert "Remove and re-enroll this passkey" in detail
|
||||
assert action == "Review passkeys"
|
||||
|
||||
|
||||
def test_activity_alert_hands_off_to_addressable_devices_section():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const listeners={{}};
|
||||
const element=(id='')=>({{
|
||||
id, hidden:true, disabled:false, textContent:'', children:[], className:'', dataset:{{}}, attributes:{{}},
|
||||
addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}},
|
||||
click(){{this.listeners?.click?.({{preventDefault(){{}}}});}},
|
||||
focus(){{}}, replaceChildren(){{this.children=[];}}, append(...values){{this.children.push(...values);}},
|
||||
setAttribute(name,value){{this.attributes[name]=String(value);}}, removeAttribute(name){{delete this.attributes[name];}},
|
||||
scrollIntoView(){{this.scrolled=true;}},
|
||||
}});
|
||||
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
|
||||
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
|
||||
'security-activity-status','load-more-security-activity','close-active-devices',
|
||||
'security-section-activity','security-section-devices','security-section-passkeys',
|
||||
'security-activity-section','security-devices-section','security-passkeys-section'];
|
||||
const ids=Object.fromEntries(names.map(id=>[id,element(id)]));
|
||||
const pushed=[];
|
||||
const root={{
|
||||
document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}},
|
||||
history:{{state:null,pushState(state,_title,url){{this.state=state;pushed.push({{state,url}});}}}},
|
||||
location:{{pathname:'/dashboard/',search:'',hash:''}},
|
||||
addEventListener:(type,listener)=>listeners[type]=listener,
|
||||
}};
|
||||
const boundary={{
|
||||
listActiveDevices:async()=>[], listPasskeys:async()=>[],
|
||||
listSecurityEvents:async()=>({{events:[],authentication_alerts:[{{}}],next_cursor:null}}),
|
||||
formatAuthenticationAlert:()=>({{title:'Failed sign-ins',detail:'Three attempts'}}),
|
||||
}};
|
||||
(async()=>{{
|
||||
const controller=attachSecurityCenter({{root,boundary}});
|
||||
await controller.open();
|
||||
const alert=ids['security-activity-list'].children[0];
|
||||
const action=alert.children[2];
|
||||
action.click();
|
||||
console.log(JSON.stringify({{
|
||||
initial:pushed[0], latest:pushed.at(-1), action:action.textContent,
|
||||
current:ids['security-section-devices'].attributes['aria-current'],
|
||||
scrolled:ids['security-devices-section'].scrolled||false,
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
output = json.loads(completed.stdout)
|
||||
assert output["initial"]["state"]["stackchainSecuritySection"] == "activity"
|
||||
assert output["initial"]["url"] == "/dashboard/#security/activity"
|
||||
assert output["latest"]["state"]["stackchainSecuritySection"] == "devices"
|
||||
assert output["action"] == "Review devices"
|
||||
assert output["current"] == "page"
|
||||
assert output["scrolled"] is True
|
||||
|
||||
|
||||
def test_device_revocation_refreshes_inventory_and_activity_for_verification():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const element=()=>({{
|
||||
hidden:true, disabled:false, textContent:'', children:[], className:'', attributes:{{}},
|
||||
addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}},
|
||||
click(){{return this.listeners?.click?.();}}, focus(){{}}, replaceChildren(){{this.children=[];}},
|
||||
append(...values){{this.children.push(...values);}}, setAttribute(name,value){{this.attributes[name]=value;}},
|
||||
removeAttribute(name){{delete this.attributes[name];}}, scrollIntoView(){{}},
|
||||
}});
|
||||
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
|
||||
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
|
||||
'security-activity-status','load-more-security-activity','close-active-devices'];
|
||||
const ids=Object.fromEntries(names.map(id=>[id,element()]));
|
||||
const calls={{devices:0,activity:0,revoke:0}};
|
||||
const root={{document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}}}};
|
||||
const boundary={{
|
||||
listActiveDevices:async()=>{{calls.devices++;return [{{device_label:'Lost phone',created_at:1,expires_at:2,current:false}}];}},
|
||||
listPasskeys:async()=>[],
|
||||
listSecurityEvents:async()=>{{calls.activity++;return {{events:[],authentication_alerts:[],next_cursor:null}};}},
|
||||
revokeActiveDevice:async()=>{{calls.revoke++;return true;}},
|
||||
}};
|
||||
(async()=>{{
|
||||
await attachSecurityCenter({{root,boundary}}).open();
|
||||
await ids['active-devices-list'].children[0].children[1].click();
|
||||
console.log(JSON.stringify({{calls,status:ids['security-activity-status'].textContent}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
output = json.loads(completed.stdout)
|
||||
assert output["calls"] == {"devices": 2, "activity": 2, "revoke": 1}
|
||||
assert output["status"] == "No security activity yet."
|
||||
|
||||
|
||||
def test_back_unwinds_security_sections_then_closes_and_restores_focus():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const listeners={{}};
|
||||
const element=()=>({{
|
||||
hidden:true, textContent:'', children:[], attributes:{{}},
|
||||
addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}}, focus(){{this.focused=true;}},
|
||||
replaceChildren(){{this.children=[];}}, append(...values){{this.children.push(...values);}},
|
||||
setAttribute(name,value){{this.attributes[name]=value;}}, removeAttribute(name){{delete this.attributes[name];}}, scrollIntoView(){{}},
|
||||
}});
|
||||
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
|
||||
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
|
||||
'security-activity-status','load-more-security-activity','close-active-devices',
|
||||
'security-section-activity','security-section-devices','security-section-passkeys',
|
||||
'security-activity-section','security-devices-section','security-passkeys-section'];
|
||||
const ids=Object.fromEntries(names.map(id=>[id,element()]));
|
||||
const pushed=[];
|
||||
const root={{
|
||||
document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}},
|
||||
history:{{state:null,pushState(state,_title,url){{this.state=state;pushed.push(url);}}}},
|
||||
location:{{pathname:'/dashboard/',search:''}},
|
||||
addEventListener:(type,listener)=>listeners[type]=listener,
|
||||
}};
|
||||
const boundary={{
|
||||
listActiveDevices:async()=>[],listPasskeys:async()=>[],
|
||||
listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}}),
|
||||
}};
|
||||
(async()=>{{
|
||||
const controller=attachSecurityCenter({{root,boundary}});
|
||||
await controller.open();
|
||||
controller.navigate('devices');
|
||||
listeners.popstate({{state:{{stackchainSecuritySection:'activity'}}}});
|
||||
const afterFirst={{hidden:ids['active-devices-sheet'].hidden,current:ids['security-section-activity'].attributes['aria-current']}};
|
||||
listeners.popstate({{state:null}});
|
||||
console.log(JSON.stringify({{afterFirst,hidden:ids['active-devices-sheet'].hidden,focused:ids['active-devices'].focused||false,pushed}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
output = json.loads(completed.stdout)
|
||||
assert output["afterFirst"] == {"hidden": False, "current": "page"}
|
||||
assert output["hidden"] is True
|
||||
assert output["focused"] is True
|
||||
assert output["pushed"] == ["/dashboard/#security/activity", "/dashboard/#security/devices"]
|
||||
|
||||
|
||||
def test_close_uses_history_back_to_remove_security_center_route():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const element=()=>({{
|
||||
hidden:true,textContent:'',children:[],attributes:{{}},
|
||||
addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}},
|
||||
click(){{return this.listeners?.click?.({{preventDefault(){{}}}});}},focus(){{}},replaceChildren(){{this.children=[];}},
|
||||
append(...values){{this.children.push(...values);}},setAttribute(){{}},removeAttribute(){{}},scrollIntoView(){{}},
|
||||
}});
|
||||
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
|
||||
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
|
||||
'security-activity-status','load-more-security-activity','close-active-devices'];
|
||||
const ids=Object.fromEntries(names.map(id=>[id,element()]));
|
||||
let backs=0;
|
||||
const root={{
|
||||
document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}},
|
||||
history:{{state:null,pushState(state){{this.state=state;}},back(){{backs++;}}}},
|
||||
location:{{pathname:'/dashboard/',search:''}},addEventListener(){{}},
|
||||
}};
|
||||
const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}})}};
|
||||
(async()=>{{
|
||||
await attachSecurityCenter({{root,boundary}}).open();
|
||||
ids['close-active-devices'].click();
|
||||
console.log(JSON.stringify({{backs}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(completed.stdout) == {"backs": 1}
|
||||
@@ -165,13 +165,13 @@ async function dispatchPush(payload) {{
|
||||
def test_offline_activation_migration_rolls_the_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -180,7 +180,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/authored-outbox.js'" in source
|
||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||
@@ -189,7 +189,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||
assert "BASE + 'static/issue-attachment.js'" in source
|
||||
|
||||
@@ -197,14 +197,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" 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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -212,7 +212,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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -220,7 +220,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "BASE + 'static/issue-sheet.js'" in source
|
||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
@@ -230,14 +230,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" 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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -246,21 +246,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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" 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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" 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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -930,7 +930,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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -953,6 +953,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/icons/stackchain-512.png",
|
||||
"/dashboard/static/session.js",
|
||||
"/dashboard/static/feature-loader.js",
|
||||
"/dashboard/static/workspace-bootstrap.js",
|
||||
"/dashboard/static/conversation-action-hydrator.js",
|
||||
"/dashboard/static/security-center.js",
|
||||
"/dashboard/static/markdown.js",
|
||||
@@ -988,6 +989,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/today-timer.js",
|
||||
"/dashboard/static/today-recap.js",
|
||||
"/dashboard/static/today-wrap-up.js",
|
||||
"/dashboard/static/today-handoff.js",
|
||||
"/dashboard/static/today-completion.js",
|
||||
"/dashboard/static/today-readiness.js",
|
||||
"/dashboard/static/comment-next.js",
|
||||
@@ -1020,6 +1023,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/mobile-search-preview-nav.js",
|
||||
"/dashboard/static/mobile-plan-today-nav.js",
|
||||
"/dashboard/static/mobile-find-work-nav.js",
|
||||
"/dashboard/static/mobile-pull-refresh.js",
|
||||
"/dashboard/static/checklist-conflict.js",
|
||||
"/dashboard/static/voice-transcript-store.js",
|
||||
"/dashboard/static/voice-issue-capture.js",
|
||||
@@ -1039,6 +1043,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-queue-launcher.js",
|
||||
"/dashboard/static/mobile-delivery-recovery.js",
|
||||
"/dashboard/static/mobile-start-day.js",
|
||||
"/dashboard/static/update-triage-session.js",
|
||||
"/dashboard/static/update-review-handoff.js",
|
||||
@@ -1049,8 +1054,12 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/update-decision-transaction.js",
|
||||
"/dashboard/static/agenda-session-launcher.js",
|
||||
"/dashboard/static/mobile-launch.js",
|
||||
"/dashboard/static/mobile-insights.js",
|
||||
"/dashboard/static/mobile-app-shortcuts.js",
|
||||
"/dashboard/static/install-app.js",
|
||||
"/dashboard/static/private-data-inventory.js",
|
||||
"/dashboard/static/private-device-data.js",
|
||||
"/dashboard/static/device-storage.js",
|
||||
"/dashboard/static/mobile-device-setup.js",
|
||||
"/dashboard/static/mobile-search-viewport.js",
|
||||
"/dashboard/static/mobile-composer-viewport.js",
|
||||
@@ -1104,6 +1113,25 @@ def test_offline_activation_migrates_cached_optional_feature_before_deleting_old
|
||||
assert state["claimed"] is True
|
||||
|
||||
|
||||
def test_successful_optional_feature_fetch_is_cached_for_offline_reopen():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const asset = '/dashboard/feature-today-timer-test.js';
|
||||
context.self.__testOptionalFeatures.push(asset);
|
||||
const response = await dispatch('fetch', {
|
||||
method: 'GET', mode: 'cors',
|
||||
url: 'https://forge.example/dashboard/feature-today-timer-test.js',
|
||||
});
|
||||
process.stdout.write(JSON.stringify({ body: await response.text(), state }));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["body"] == "network"
|
||||
assert result["state"]["puts"] == [
|
||||
"https://forge.example/dashboard/feature-today-timer-test.js"
|
||||
]
|
||||
|
||||
|
||||
def test_activate_warms_optional_features_without_blocking_siblings_or_claim():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HANDOFF = Path(__file__).parents[1] / "frontend" / "today-handoff.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_due_wrap_up_commitments_are_reviewed_in_order_and_added_durably():
|
||||
script = f"""
|
||||
const createHandoff=require({json.dumps(str(HANDOFF))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const items=[
|
||||
{{kind:'issue',repository:'r',number:2,title:'Second'}},
|
||||
{{kind:'issue',repository:'r',number:1,title:'First'}},
|
||||
];
|
||||
const identity=item=>'issue:'+item.repository+':'+item.number+':';
|
||||
const today=[]; const operations=[];
|
||||
const controller=createHandoff({{
|
||||
storage,getLogin:()=> 'timmy',identity,
|
||||
todayWork:{{contains:item=>today.includes(identity(item)),add:item=>{{if(today.length>=1)return 'full';today.push(identity(item));return 'added';}}}},
|
||||
todaySync:{{enqueue:(action,id)=>{{operations.push([action,id]);return true;}},flush:async()=>true}},
|
||||
}});
|
||||
controller.capture(['issue:r:1:','issue:r:2:','issue:r:1:']);
|
||||
const review=controller.review(items);
|
||||
controller.choose('issue:r:2:',false);
|
||||
const first=await controller.commit(items);
|
||||
controller.choose('issue:r:2:',true);
|
||||
const blocked=await controller.commit(items);
|
||||
process.stdout.write(JSON.stringify({{review,first,blocked,today,operations,pending:controller.pending()}}));
|
||||
"""
|
||||
result = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert [row["identity"] for row in result["review"]] == ["issue:r:1:", "issue:r:2:"]
|
||||
assert all(row["selected"] for row in result["review"])
|
||||
assert result["first"] == {"scheduled": 1, "blocked": 0, "remaining": 1}
|
||||
assert result["blocked"] == {"scheduled": 0, "blocked": 1, "remaining": 1}
|
||||
assert result["today"] == ["issue:r:1:"]
|
||||
assert result["operations"] == [["add", "issue:r:1:"]]
|
||||
assert result["pending"] == ["issue:r:2:"]
|
||||
|
||||
|
||||
def test_handoff_pending_state_is_account_scoped_and_capture_is_idempotent():
|
||||
script = f"""
|
||||
const createHandoff=require({json.dumps(str(HANDOFF))});
|
||||
const values=new Map();let login='timmy';
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const options={{storage,getLogin:()=>login,identity:item=>item.id,todayWork:{{contains:()=>false,add:()=> 'added'}},todaySync:{{enqueue:()=>true,flush:async()=>true}}}};
|
||||
const controller=createHandoff(options);
|
||||
controller.capture(['one','two']);controller.capture(['two','one']);
|
||||
const timmy=controller.pending();login='alexander';const isolated=controller.pending();
|
||||
login='timmy';const resumed=createHandoff(options).pending();
|
||||
process.stdout.write(JSON.stringify({{timmy,isolated,resumed}}));
|
||||
"""
|
||||
assert run_node(f"(async()=>{{{script}}})()") == {
|
||||
"timmy": ["one", "two"], "isolated": [], "resumed": ["one", "two"],
|
||||
}
|
||||
|
||||
|
||||
def test_dashboard_packages_accessible_mobile_commitment_review_and_account_sync():
|
||||
from src import main
|
||||
|
||||
html = main.FRONTEND_BUILD.dashboard_html
|
||||
dashboard = (HANDOFF.parents[0] / "dashboard.js").read_text()
|
||||
css = (HANDOFF.parents[0] / "dashboard.css").read_text()
|
||||
service_worker = (HANDOFF.parents[0] / "service-worker.js").read_text()
|
||||
|
||||
assert 'id="today-handoff-dialog"' in html
|
||||
assert 'aria-labelledby="today-handoff-title"' in html
|
||||
assert 'id="today-handoff-items"' in html
|
||||
assert 'id="confirm-today-handoff"' in html
|
||||
assert '<script src="static/today-handoff.js"></script>' in (HANDOFF.parents[0] / "index.html").read_text()
|
||||
assert "createTodayHandoff({" in dashboard
|
||||
assert "laterSync.enqueue(action, itemId, wakeAt, handoff)" in dashboard
|
||||
assert "todayHandoff.capture(handoffs)" in dashboard
|
||||
assert ".today-handoff-actions button { min-height:44px;" in css
|
||||
assert "BASE + 'static/today-handoff.js'" in service_worker
|
||||
assert "static/today-handoff.js" in main.FRONTEND_BUILD.page_sources
|
||||
@@ -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-v114';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v117';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -876,13 +876,13 @@ async def test_dashboard_renders_mobile_today_recap_flow():
|
||||
assert ".today-recap-header button { min-height:44px;" in css
|
||||
assert ".today-recap-actions button { min-height:44px;" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
assert "Save recap & adjust plan" in html
|
||||
assert "Save recap & wrap up" in html
|
||||
assert "todayRecapFeedbackRows(draft, describeWork)" in recap_source
|
||||
assert 'class="today-recap-variance"' in recap_source
|
||||
assert "await recap.saveForReplan()" in recap_source
|
||||
assert "adjustPlan(handoff.actual_minutes)" in recap_source
|
||||
assert "openWrapUp(handoff.actual_minutes)" in recap_source
|
||||
assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard
|
||||
assert "openPlanToday(qs('#plan-today'), true, actualMinutes)" in dashboard
|
||||
assert "todayWrapUpView.open(todayMyWork, actualMinutes)" in dashboard
|
||||
assert ".today-recap-row { grid-template-columns:1fr; }" in css
|
||||
|
||||
|
||||
|
||||
@@ -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-v114" in source
|
||||
assert "stackchain-dashboard-shell-v117" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
TODAY_WRAP_UP = ROOT / "frontend" / "today-wrap-up.js"
|
||||
|
||||
|
||||
def run_node(script: str):
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_wrap_up_schedules_selected_work_for_tomorrow_and_leaves_other_work_in_order():
|
||||
script = f"""
|
||||
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
||||
const items = [
|
||||
{{kind:'issue', repository:'stackchain/dashboard', number:1, title:'First'}},
|
||||
{{kind:'issue', repository:'stackchain/dashboard', number:2, title:'Second'}},
|
||||
{{kind:'pull', repository:'stackchain/dashboard', number:3, title:'Third'}},
|
||||
];
|
||||
const ids = items.map(item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':');
|
||||
const today = [...ids];
|
||||
const later = {{}};
|
||||
const todayOps = [];
|
||||
const laterOps = [];
|
||||
const todayWork = {{
|
||||
identity:item => (item.kind || 'work') + ':' + item.repository + ':' + item.number + ':',
|
||||
read:() => [...today],
|
||||
contains:item => today.includes((item.kind || 'work') + ':' + item.repository + ':' + item.number + ':'),
|
||||
remove:item => {{
|
||||
const id=(item.kind || 'work') + ':' + item.repository + ':' + item.number + ':';
|
||||
const index=today.indexOf(id); if(index < 0) return false; today.splice(index,1); return true;
|
||||
}},
|
||||
}};
|
||||
const laterWork = {{
|
||||
identity:todayWork.identity,
|
||||
presetUntil:preset => new Date('2026-08-17T09:00:00-04:00'),
|
||||
defer:(item, until, options) => {{ later[todayWork.identity(item)] = until.toISOString(); laterOps.push([todayWork.identity(item), options]); return 'deferred'; }},
|
||||
}};
|
||||
const todaySync = {{enqueue:(action,id) => {{todayOps.push([action,id]); return true;}}, flush:()=>Promise.resolve(true)}};
|
||||
const wrap = createWrapUp({{todayWork,laterWork,todaySync}});
|
||||
wrap.open(items);
|
||||
wrap.choose(ids[1], true);
|
||||
const result = await wrap.finish();
|
||||
process.stdout.write(JSON.stringify({{result,today,later,todayOps,laterOps}}));
|
||||
"""
|
||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert output["result"] == {"scheduled": 1, "left": 2, "wake_at": "2026-08-17T13:00:00.000Z"}
|
||||
assert output["today"] == ["issue:stackchain/dashboard:1:", "pull:stackchain/dashboard:3:"]
|
||||
assert output["later"] == {"issue:stackchain/dashboard:2:": "2026-08-17T13:00:00.000Z"}
|
||||
assert output["todayOps"] == [["remove", "issue:stackchain/dashboard:2:"]]
|
||||
assert output["laterOps"] == [["issue:stackchain/dashboard:2:", {"handoff": "today"}]]
|
||||
|
||||
|
||||
def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_save():
|
||||
from src import main
|
||||
|
||||
html = main.FRONTEND_BUILD.dashboard_html
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
recap = (ROOT / "frontend" / "today-recap.js").read_text()
|
||||
|
||||
assert 'id="today-wrap-up-sheet" role="dialog"' in html
|
||||
assert 'id="today-wrap-up-items"' in html
|
||||
assert 'id="finish-today-wrap-up"' in html
|
||||
assert 'static/today-wrap-up.js' in main.FRONTEND_BUILD.page_sources
|
||||
assert 'setupTodayWrapUp({' in dashboard
|
||||
assert 'todayWrapUpView.open' in dashboard
|
||||
assert 'openWrapUp(handoff.actual_minutes)' in recap
|
||||
assert '.today-wrap-up-actions button { min-height:44px;' in css
|
||||
assert '.today-wrap-up-panel' in css and 'overflow-x:hidden' in css
|
||||
assert 'tests/e2e/test_mobile_today_wrap_up_release.py' in (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text()
|
||||
|
||||
|
||||
def test_wrap_up_repeat_confirmation_does_not_repeat_later_or_today_operations():
|
||||
script = f"""
|
||||
const createWrapUp = require({json.dumps(str(TODAY_WRAP_UP))});
|
||||
const item={{kind:'issue',repository:'acme/mobile',number:41,title:'Ship'}};
|
||||
const id='issue:acme/mobile:41:';
|
||||
let present=true; let deferred=0; let removed=0;
|
||||
const todayWork={{identity:()=>id,read:()=>present?[id]:[],contains:()=>present,remove:()=>{{present=false;return true;}}}};
|
||||
const laterWork={{presetUntil:()=>new Date('2026-08-17T09:00:00Z'),defer:()=>{{deferred+=1;return 'deferred';}}}};
|
||||
const todaySync={{enqueue:()=>{{removed+=1;return true;}},flush:()=>Promise.resolve(true)}};
|
||||
const wrap=createWrapUp({{todayWork,laterWork,todaySync}});
|
||||
wrap.open([item]); wrap.choose(id,true);
|
||||
await wrap.finish(); await wrap.finish();
|
||||
process.stdout.write(JSON.stringify({{deferred,removed,present}}));
|
||||
"""
|
||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert output == {"deferred": 1, "removed": 1, "present": False}
|
||||
@@ -0,0 +1,124 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BOOTSTRAP = Path(__file__).parents[1] / "frontend" / "workspace-bootstrap.js"
|
||||
|
||||
|
||||
def run_bootstrap(scenario: str) -> dict:
|
||||
harness = f"""
|
||||
const loadWorkspace = require({json.dumps(str(BOOTSTRAP))});
|
||||
(async()=>{{ {scenario} }})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_workspace_bootstrap_loads_content_addressed_feature_before_startup():
|
||||
result = run_bootstrap("""
|
||||
const status={textContent:''};
|
||||
const document={
|
||||
querySelector(selector) {
|
||||
if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'};
|
||||
if (selector === '#my-work-action-status') return status;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
let requested='';
|
||||
const createLoader=options=>({load:async name=>{requested=name + ':' + options.urls[name];}});
|
||||
await loadWorkspace({document,createLoader});
|
||||
console.log(JSON.stringify({requested,status:status.textContent}));
|
||||
""")
|
||||
assert result == {"requested": "today-timer:feature-workspace-abc.js", "status": ""}
|
||||
|
||||
|
||||
def test_workspace_bootstrap_keeps_shell_and_announces_retry_when_feature_fails():
|
||||
result = run_bootstrap("""
|
||||
const status={textContent:''};
|
||||
const document={
|
||||
querySelector(selector) {
|
||||
if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'};
|
||||
if (selector === '#my-work-action-status') return status;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const createLoader=()=>({load:async()=>{throw new Error('network failed');}});
|
||||
let message='';
|
||||
try { await loadWorkspace({document,createLoader}); } catch (error) { message=error.message; }
|
||||
console.log(JSON.stringify({message,status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"message": "network failed",
|
||||
"status": "Workspace could not load. Check your connection, then reload to retry.",
|
||||
}
|
||||
|
||||
|
||||
def test_workspace_bootstrap_reloads_after_a_failed_offline_load_reconnects():
|
||||
result = run_bootstrap("""
|
||||
const status={textContent:''}; const listeners={}; let reloads=0;
|
||||
const document={querySelector(selector) {
|
||||
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
|
||||
if (selector === '#my-work-action-status') return status;
|
||||
return null;
|
||||
}};
|
||||
const window={
|
||||
location:{reload(){reloads++;}},
|
||||
addEventListener(name,callback) { listeners[name]=callback; },
|
||||
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
|
||||
};
|
||||
const createLoader=()=>({load:async()=>{throw new Error('offline');}});
|
||||
try { await loadWorkspace({document,window,createLoader}); } catch (error) {}
|
||||
const waiting=Boolean(listeners.online);
|
||||
if (listeners.online) listeners.online();
|
||||
console.log(JSON.stringify({waiting,reloads,status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"waiting": True,
|
||||
"reloads": 1,
|
||||
"status": "Workspace could not load. Reconnect to retry automatically, or reload now.",
|
||||
}
|
||||
|
||||
|
||||
def test_workspace_bootstrap_reloads_when_reconnect_arrives_before_load_failure():
|
||||
result = run_bootstrap("""
|
||||
const status={textContent:''}; const listeners={}; let reloads=0;
|
||||
const document={querySelector(selector) {
|
||||
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
|
||||
if (selector === '#my-work-action-status') return status;
|
||||
return null;
|
||||
}};
|
||||
const window={
|
||||
location:{reload(){reloads++;}},
|
||||
addEventListener(name,callback) { listeners[name]=callback; },
|
||||
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
|
||||
};
|
||||
const createLoader=()=>({load:async()=>{listeners.online(); throw new Error('offline request');}});
|
||||
try { await loadWorkspace({document,window,createLoader}); } catch (error) {}
|
||||
console.log(JSON.stringify({reloads,waiting:Boolean(listeners.online)}));
|
||||
""")
|
||||
assert result == {"reloads": 1, "waiting": False}
|
||||
|
||||
|
||||
def test_workspace_bootstrap_replays_online_event_after_dashboard_registers_handlers():
|
||||
result = run_bootstrap("""
|
||||
const status={textContent:''}; const listeners={};
|
||||
const document={querySelector(selector) {
|
||||
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
|
||||
if (selector === '#my-work-action-status') return status;
|
||||
return null;
|
||||
}};
|
||||
const window={
|
||||
addEventListener(name,callback) { listeners[name]=callback; },
|
||||
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
|
||||
};
|
||||
const createLoader=()=>({load:async()=>{listeners.online();}});
|
||||
const lifecycle=await loadWorkspace({document,window,createLoader});
|
||||
let reconnects=0;
|
||||
lifecycle.replayOnline(()=>{reconnects++;});
|
||||
lifecycle.replayOnline(()=>{reconnects++;});
|
||||
console.log(JSON.stringify({reconnects,listening:Boolean(listeners.online)}));
|
||||
""")
|
||||
assert result == {"reconnects": 1, "listening": False}
|
||||
Reference in new issue
Block a user