Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebb7a78cab | ||
|
|
b4a5401b22 | ||
|
|
870edda123 | ||
|
|
156d4b0ffc | ||
|
|
304e4a8a24 | ||
|
|
8731682090 | ||
|
|
348f43f1bc | ||
|
|
161563d6f5 | ||
|
|
931c82361b | ||
|
|
61b332e814 | ||
|
|
4b3e53f4d0 | ||
|
|
f3c9e89920 | ||
|
|
1248647aa4 | ||
|
|
b3832d107f | ||
|
|
a0da8e9fb2 | ||
|
|
8794200c2a | ||
|
|
c2526969b1 | ||
|
|
3b4f6b39a5 | ||
|
|
a9cf88b3ff | ||
|
|
3d4be0a913 | ||
|
|
9252807364 | ||
|
|
db85bfa5f9 | ||
|
|
3822ec4536 | ||
|
|
b278367e3b | ||
|
|
13e73bd342 | ||
|
|
1047f3ecdc |
No files matched your search
@@ -57,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 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_today_summary_release.py tests/e2e/test_mobile_wrap_up_handoff_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_sign_out_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -193,11 +193,18 @@ filesystem boundary independently of the service umask: the database directory i
|
||||
owner-only `0700`, database and SQLite sidecar files are owner-only `0600`, and symlinked database
|
||||
paths are rejected before access. Worker-shared live and Find Work snapshots add AES-256-GCM
|
||||
envelopes authenticated to their store identity (and live generation), so copied databases do not
|
||||
expose issue bodies, titles, notification metadata, or repository context. Existing plaintext
|
||||
snapshot rows migrate on their first read without changing freshness, revisions, ordering, or claim
|
||||
filters. Synchronized unfiled Draft collections use a separate AES-256-GCM key and authenticate the
|
||||
account and revision; existing plaintext rows likewise migrate on first read. Other private stores
|
||||
are not encrypted at the application layer. Web Push subscriptions use a third, independent
|
||||
expose issue bodies, titles, notification metadata, or repository context. The authored-action
|
||||
idempotency ledger encrypts both request fingerprints and confirmed upstream responses, authenticating
|
||||
each envelope to its operation key and field purpose so rows and fields cannot be substituted.
|
||||
Existing plaintext snapshot and ledger rows migrate atomically on their first read without changing
|
||||
freshness, revisions, ordering, replay, or conflict semantics. Synchronized unfiled Draft collections
|
||||
use a separate AES-256-GCM key and authenticate the account and revision; existing plaintext rows
|
||||
likewise migrate on first read. Synchronized Saved Search collections use the private-state key and
|
||||
authenticate each envelope to its normalized account, preventing rows from being substituted between
|
||||
operators. Existing plaintext Saved Searches migrate atomically on first read without advancing their
|
||||
revision; missing, wrong, or modified key material returns no saved-view content. Other private stores
|
||||
are not encrypted at the application layer. Web
|
||||
Push subscriptions use a third, independent
|
||||
AES-256-GCM key authenticated to the device session, with keyed endpoint indexes preserving
|
||||
single-device enrollment without retaining capability URLs. Existing plaintext subscriptions
|
||||
migrate atomically at startup without resetting delivery checkpoints or reminder schedules. Secure
|
||||
@@ -268,12 +275,12 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||
# Required for worker-shared live/Find Work snapshots, synchronized Today/Later
|
||||
# planning state, and the Security activity journal. Keep this key independent
|
||||
# planning and Saved Search state, and the Security activity journal. Keep this key independent
|
||||
# from the Draft key and inject the
|
||||
# base64 encoding of exactly 32 random bytes from a secret manager. Never commit
|
||||
# it. Missing, malformed, wrong-key, or modified state fails closed without
|
||||
# returning content. Legacy Today/Later and Security activity rows migrate
|
||||
# atomically on first use without changing logical revisions or journal IDs.
|
||||
# returning content. Legacy Today/Later, Saved Search, and Security activity rows
|
||||
# migrate atomically on first use without changing logical revisions or journal IDs.
|
||||
export STACKCHAIN_PRIVATE_STATE_ENCRYPTION_KEY='<base64-encoded-32-byte-key>'
|
||||
# Required for cross-device unfiled Draft sync. The single-key setting remains
|
||||
# supported for the first deployment of keyring-capable code and writes v1 envelopes.
|
||||
|
||||
+26
-1
@@ -6,12 +6,22 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
|
||||
.sign-out-review-sheet { position:fixed; inset:0; z-index:112; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.sign-out-review-sheet[hidden] { display:none; }
|
||||
.sign-out-review-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; overflow-wrap:anywhere; }
|
||||
.sign-out-review-panel h2 { margin:.25rem 0; }
|
||||
.sign-out-review-warning { color:#fbbf24; }
|
||||
.sign-out-review-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
.sign-out-review-actions button { min-height:44px; width:100%; }
|
||||
#confirm-sign-out { border-color:#b45309; }
|
||||
@media (max-width:359px) { .sign-out-review-actions { grid-template-columns:1fr; } }
|
||||
.live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.live-data-status-sheet[hidden] { display:none; }
|
||||
.live-data-status-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.live-data-status-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.live-data-status-header h2, .live-data-status-header p { margin-top:0; }
|
||||
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
|
||||
.live-data-status-today-paused { padding:10px 12px; border:1px solid #4ade80; border-radius:10px; color:#bbf7d0; background:#10291e; }
|
||||
.issue-filing-receipt { position:fixed; inset:0; z-index:108; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.issue-filing-receipt[hidden] { display:none; }
|
||||
.release-receipt-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
|
||||
@@ -367,8 +377,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.today-summary-note { display:grid; gap:6px; margin:14px 0; font-weight:700; }
|
||||
.today-summary-note textarea { box-sizing:border-box; width:100%; min-height:88px; resize:vertical; }
|
||||
.today-summary-preview { min-height:72px; white-space:pre-wrap; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#07101e; color:#e8f1ff; }
|
||||
.today-summary-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-summary-destination { margin-top:14px; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#0d1c31; }
|
||||
.today-summary-destination h3 { margin-top:0; }
|
||||
.today-summary-destination-controls { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
.today-summary-destination-controls input, .today-summary-destination-controls button { box-sizing:border-box; min-width:0; min-height:44px; }
|
||||
.today-summary-target { min-height:1.4em; margin-top:8px; }
|
||||
.today-summary-actions { position:sticky; bottom:0; display:grid; grid-template-columns:repeat(3,minmax(0,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-summary-actions button { min-height:44px; width:100%; }
|
||||
@media (max-width:420px) { .today-summary-destination-controls, .today-summary-actions { grid-template-columns:1fr; } }
|
||||
.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)); }
|
||||
@@ -1153,6 +1169,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
|
||||
.mobile-first-task { 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-first-task::backdrop { background:rgba(3,9,18,.78); }
|
||||
.mobile-first-task-panel { display:grid; gap:14px; padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-first-task-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.mobile-first-task-panel h2, .mobile-first-task-panel p { margin:0; }
|
||||
.mobile-first-task-actions { display:grid; gap:10px; }
|
||||
.mobile-first-task-actions button { min-height:48px; width:100%; }
|
||||
.mobile-first-task-actions button:disabled { opacity:.55; }
|
||||
|
||||
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
|
||||
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
|
||||
+25
-19
@@ -140,6 +140,12 @@
|
||||
if (!sheet.open) qs('#mobile-queue-sheet').showModal();
|
||||
qs('#mobile-start-day-action').focus();
|
||||
}
|
||||
const mobileFirstTask = createMobileFirstTask({
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
hasWork: () => todayMyWork.length > 0 || activeMyWork.length > 0,
|
||||
isTodayActive: () => workSession.checkpointed(),
|
||||
});
|
||||
mobileFirstTask.start();
|
||||
const mobileWorkEntry = createMobileWorkEntry({
|
||||
isTodayActive: () => workSession.checkpointed(),
|
||||
isTodayResumable: () => workSession.resumable(),
|
||||
@@ -152,6 +158,8 @@
|
||||
startToday: startTodaySession,
|
||||
planToday: () => openPlanToday(mobileTaskButtons.work),
|
||||
prepareToday: openMobileStartDay,
|
||||
shouldActivate: () => mobileFirstTask.required(),
|
||||
openActivation: () => mobileFirstTask.open(),
|
||||
findWork: () => qs('#find-work').click(),
|
||||
});
|
||||
const mobileStartDay = createMobileStartDay({
|
||||
@@ -1929,7 +1937,10 @@
|
||||
interruptionPrompt.resolve(button.dataset.todayInterruption)
|
||||
);
|
||||
});
|
||||
const todaySummaryView = setupTodaySummary({ qs, escapeHtml, getLogin:() => planningOwnerLogin });
|
||||
const todaySummaryView = setupTodaySummary({
|
||||
qs, escapeHtml, getLogin:() => planningOwnerLogin, fetchJson:api,
|
||||
enqueueDurably:message => authoredOutbox.enqueueDurably(message),
|
||||
});
|
||||
todaySummaryView.resume();
|
||||
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
|
||||
onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => {
|
||||
@@ -3222,6 +3233,7 @@
|
||||
});
|
||||
mobileStartDay.render();
|
||||
mobileTaskDock.updateQueues(counts);
|
||||
mobileFirstTask.refresh();
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode());
|
||||
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
|
||||
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
|
||||
@@ -7448,24 +7460,7 @@
|
||||
'Next automatic retry in ' + retrySeconds + 's.' :
|
||||
(pollState.lastSuccessAt ? 'Last successful refresh ' + fmt(new Date(pollState.lastSuccessAt)) + '.' : 'Waiting for the first successful refresh.');
|
||||
}
|
||||
function closeLiveDataStatus() {
|
||||
liveDataStatusSheet.hidden = true;
|
||||
liveDataStatusTrigger.setAttribute('aria-expanded', 'false');
|
||||
liveDataStatusTrigger.focus();
|
||||
}
|
||||
liveDataStatusTrigger.addEventListener('click', () => {
|
||||
renderLiveDataStatus();
|
||||
liveDataStatusSheet.hidden = false;
|
||||
liveDataStatusTrigger.setAttribute('aria-expanded', 'true');
|
||||
qs('#close-live-data-status').focus();
|
||||
});
|
||||
qs('#close-live-data-status').addEventListener('click', closeLiveDataStatus);
|
||||
liveDataStatusSheet.addEventListener('click', event => {
|
||||
if (event.target === liveDataStatusSheet) closeLiveDataStatus();
|
||||
});
|
||||
liveDataStatusSheet.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); closeLiveDataStatus(); }
|
||||
});
|
||||
liveDataStatus.mount({document, window, timerView, onOpen:renderLiveDataStatus}).start();
|
||||
const liveDataRefresh = liveDataStatus.createRefreshController({
|
||||
button: qs('#refresh-live-data'),
|
||||
output: qs('#live-data-status-result'),
|
||||
@@ -7924,6 +7919,7 @@
|
||||
const controller = createPushNotifications({
|
||||
control:qs('#push-updates'),
|
||||
status:qs('#push-update-status'),
|
||||
testControl:qs('#push-test'),
|
||||
deadlineControl:qs('#push-deadlines'),
|
||||
deadlineStatus:qs('#push-deadline-status'),
|
||||
deadlineHour:qs('#push-deadline-hour'),
|
||||
@@ -7986,9 +7982,15 @@
|
||||
enableOffline:() => setOfflineWorkEnabled(true),
|
||||
storageProtectionReadiness:() => deviceStorage.persistenceReadiness(),
|
||||
protectStorage:() => deviceStorage.requestPersistence(),
|
||||
notificationReadiness:() => pushController?.notificationReadiness()
|
||||
|| {state:'unavailable', detail:'Update notifications are unavailable.'},
|
||||
enablePush:async () => {
|
||||
const controller = await pushControllerReady;
|
||||
if (!controller) return;
|
||||
if (controller.notificationReadiness().state === 'blocked') {
|
||||
await controller.recoverPermission('updates');
|
||||
return;
|
||||
}
|
||||
qs('#push-updates').checked = true;
|
||||
await controller.change();
|
||||
},
|
||||
@@ -7999,6 +8001,10 @@
|
||||
if (!controller) return;
|
||||
qs('#push-deadline-hour').value = qs('#device-setup-deadline-hour').value;
|
||||
qs('#push-deadline-days').value = qs('#device-setup-deadline-days').value;
|
||||
if (controller.deadlineReadiness().state === 'blocked') {
|
||||
await controller.recoverPermission('deadline');
|
||||
return;
|
||||
}
|
||||
await controller.enableDeadline();
|
||||
},
|
||||
});
|
||||
|
||||
+45
-1
@@ -27,16 +27,29 @@
|
||||
</div>
|
||||
</details>
|
||||
</header>
|
||||
<div id="sign-out-review-sheet" class="sign-out-review-sheet" hidden>
|
||||
<section class="sign-out-review-panel" role="dialog" aria-modal="true" aria-labelledby="sign-out-review-heading">
|
||||
<h2 id="sign-out-review-heading">Review sign out</h2>
|
||||
<p id="sign-out-review-summary" class="small" role="status" aria-live="polite">Checking private work…</p>
|
||||
<p id="sign-out-review-warning" class="sign-out-review-warning"></p>
|
||||
<div class="sign-out-review-actions">
|
||||
<button id="cancel-sign-out" type="button">Cancel</button>
|
||||
<button id="confirm-sign-out" type="button">Sign out</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div id="live-data-status-sheet" class="live-data-status-sheet" hidden>
|
||||
<section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading">
|
||||
<div class="live-data-status-header">
|
||||
<div><h2 id="live-data-status-heading">Live data status</h2><p class="small muted">Check which dashboard feeds are current before acting.</p></div>
|
||||
<button id="close-live-data-status" type="button" aria-label="Close live data status">Close</button>
|
||||
</div>
|
||||
<p id="live-data-status-today-paused" class="live-data-status-today-paused" role="status" hidden>Today paused while you check live data.</p>
|
||||
<div id="live-data-status-feeds" class="live-data-status-feeds"></div>
|
||||
<p id="live-data-status-retry" class="small muted"></p>
|
||||
<div class="live-data-status-actions">
|
||||
<button id="refresh-live-data" type="button">Refresh now</button>
|
||||
<button id="return-from-live-data-status" type="button">Return to Today</button>
|
||||
<span id="live-data-status-result" class="small" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -190,6 +203,7 @@
|
||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
|
||||
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
|
||||
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
|
||||
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
|
||||
@@ -313,6 +327,7 @@
|
||||
<div class="small" id="notification-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
|
||||
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
||||
<button class="retry-work-route" id="retry-workspace" type="button" hidden>Retry workspace</button>
|
||||
<button class="release-receipt-launcher" id="release-receipt-launcher" type="button" hidden>Merged · tracking release</button>
|
||||
<button class="retry-work-route" id="retry-unfiled-draft-sync" type="button" hidden>Retry Draft sync</button>
|
||||
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
|
||||
@@ -545,7 +560,18 @@
|
||||
<label class="today-summary-note">Optional note<textarea id="today-summary-note" maxlength="1000" rows="3"></textarea></label>
|
||||
<h3>Preview</h3>
|
||||
<pre id="today-summary-preview" class="today-summary-preview"></pre>
|
||||
<section class="today-summary-destination" aria-labelledby="today-summary-destination-title">
|
||||
<h3 id="today-summary-destination-title">Post to Gitea</h3>
|
||||
<p class="small muted">Enter the exact issue or pull request that should receive this reviewed comment.</p>
|
||||
<label for="today-summary-destination">Destination</label>
|
||||
<div class="today-summary-destination-controls">
|
||||
<input id="today-summary-destination" type="text" inputmode="text" autocomplete="off" maxlength="260" placeholder="owner/repo#42">
|
||||
<button id="validate-today-summary-destination" type="button">Review destination</button>
|
||||
</div>
|
||||
<div id="today-summary-target" class="today-summary-target small" aria-live="polite"></div>
|
||||
</section>
|
||||
<div class="today-summary-actions">
|
||||
<button id="post-today-summary" type="button" disabled>Post to Gitea</button>
|
||||
<button id="share-today-summary" type="button">Share summary</button>
|
||||
<button id="discard-today-summary" type="button">Discard draft</button>
|
||||
</div>
|
||||
@@ -1803,6 +1829,22 @@
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<dialog class="mobile-first-task" id="mobile-first-task" aria-labelledby="mobile-first-task-title">
|
||||
<section class="mobile-first-task-panel">
|
||||
<header>
|
||||
<div><p class="small muted">Welcome to Work</p><h2 id="mobile-first-task-title">Start your first task</h2></div>
|
||||
<button id="close-mobile-first-task" type="button">Close</button>
|
||||
</header>
|
||||
<p class="small">Choose work, start it, then return to Work whenever you want to continue.</p>
|
||||
<p id="mobile-first-task-status" class="small" role="status" aria-live="polite"></p>
|
||||
<div class="mobile-first-task-actions">
|
||||
<button id="mobile-first-task-find" type="button">Find & start</button>
|
||||
<button id="mobile-first-task-create" type="button">Create & start</button>
|
||||
<button id="mobile-first-task-setup" type="button">Make this phone work-ready</button>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
||||
@@ -1814,6 +1856,8 @@
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/private-data-registry.js"></script>
|
||||
<script src="static/private-data-inventory.js"></script>
|
||||
<script src="static/sign-out-review.js"></script>
|
||||
<script src="static/session.js"></script>
|
||||
<script src="static/feature-loader.js"></script>
|
||||
<script src="static/conversation-action-hydrator.js"></script>
|
||||
@@ -1905,6 +1949,7 @@
|
||||
<script src="static/mobile-today-command-bar.js"></script>
|
||||
<script src="static/mobile-task-dock.js"></script>
|
||||
<script src="static/mobile-pull-refresh.js"></script>
|
||||
<script src="static/mobile-first-task.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>
|
||||
@@ -1921,7 +1966,6 @@
|
||||
<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>
|
||||
|
||||
@@ -74,5 +74,112 @@
|
||||
return { run };
|
||||
}
|
||||
|
||||
return { describe, createRefreshController };
|
||||
function createSheetController(options) {
|
||||
let ownsDetour = false;
|
||||
let backgroundState = null;
|
||||
|
||||
function focusableControls() {
|
||||
return [...options.sheet.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])')]
|
||||
.filter(control => !control.hidden);
|
||||
}
|
||||
|
||||
function containBackground() {
|
||||
if (backgroundState) return;
|
||||
backgroundState = new Map((options.backgroundElements || []).map(element => [element, element.inert]));
|
||||
backgroundState.forEach((_wasInert, element) => { element.inert = true; });
|
||||
}
|
||||
|
||||
function releaseBackground() {
|
||||
if (!backgroundState) return;
|
||||
backgroundState.forEach((wasInert, element) => { element.inert = wasInert; });
|
||||
backgroundState = null;
|
||||
}
|
||||
|
||||
function finishClose() {
|
||||
if (options.sheet.hidden) return;
|
||||
options.sheet.hidden = true;
|
||||
options.trigger.setAttribute('aria-expanded', 'false');
|
||||
if (options.pausedStatus) options.pausedStatus.hidden = true;
|
||||
releaseBackground();
|
||||
if (ownsDetour) options.timerView?.finishDetour?.();
|
||||
ownsDetour = false;
|
||||
options.trigger.focus?.();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (options.history?.state?.liveDataStatus) {
|
||||
options.history.back();
|
||||
return;
|
||||
}
|
||||
finishClose();
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!options.sheet.hidden) return;
|
||||
ownsDetour = options.timerView?.beginDetour?.('live-data-status')?.reason === 'live-data-status';
|
||||
if (options.pausedStatus) options.pausedStatus.hidden = !ownsDetour;
|
||||
if (options.returnButton) options.returnButton.hidden = !ownsDetour;
|
||||
containBackground();
|
||||
options.sheet.hidden = false;
|
||||
options.trigger.setAttribute('aria-expanded', 'true');
|
||||
if (options.history && !options.history.state?.liveDataStatus) {
|
||||
options.history.pushState({ ...options.history.state, liveDataStatus:true }, '');
|
||||
}
|
||||
options.closeButton.focus();
|
||||
}
|
||||
|
||||
function start() {
|
||||
options.trigger.addEventListener('click', open);
|
||||
options.closeButton.addEventListener('click', close);
|
||||
options.returnButton?.addEventListener('click', close);
|
||||
options.sheet.addEventListener('click', event => {
|
||||
if (event.target === options.sheet) close();
|
||||
});
|
||||
options.escapeTarget?.addEventListener('keydown', event => {
|
||||
if (options.sheet.hidden) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = focusableControls();
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && event.target === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && event.target === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
options.historyTarget?.addEventListener('popstate', event => {
|
||||
if (!options.sheet.hidden && !event.state?.liveDataStatus) finishClose();
|
||||
});
|
||||
}
|
||||
|
||||
return { start, open, close };
|
||||
}
|
||||
|
||||
function mount(options) {
|
||||
const document = options.document;
|
||||
const qs = selector => document.querySelector(selector);
|
||||
const trigger = qs('#open-live-data-status');
|
||||
trigger.addEventListener('click', options.onOpen);
|
||||
return createSheetController({
|
||||
sheet:qs('#live-data-status-sheet'), trigger,
|
||||
closeButton:qs('#close-live-data-status'),
|
||||
returnButton:qs('#return-from-live-data-status'),
|
||||
pausedStatus:qs('#live-data-status-today-paused'),
|
||||
timerView:options.timerView,
|
||||
history:options.window.history,
|
||||
historyTarget:options.window,
|
||||
escapeTarget:document,
|
||||
backgroundElements:[qs('header'), qs('main'), qs('#mobile-task-dock')].filter(Boolean),
|
||||
});
|
||||
}
|
||||
|
||||
return { describe, createRefreshController, createSheetController, mount };
|
||||
});
|
||||
@@ -5,14 +5,34 @@
|
||||
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
|
||||
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const steps = [
|
||||
['install', options.installButton, options.installStatus, options.install],
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
|
||||
['protection', options.protectionButton, options.protectionStatus, options.protectStorage],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
|
||||
['install', options.installButton, options.installStatus, options.install, 'Install'],
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline, 'Enable'],
|
||||
['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
|
||||
];
|
||||
let trigger = options.launcher;
|
||||
let ownsDetour = false;
|
||||
let backgroundInert = null;
|
||||
let pendingResume = true;
|
||||
|
||||
function focusableControls() {
|
||||
return [...options.sheet.querySelectorAll(
|
||||
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)].filter(control => !control.hidden);
|
||||
}
|
||||
|
||||
function containBackground() {
|
||||
if (backgroundInert) return;
|
||||
backgroundInert = new Map((options.backgroundElements || []).map(element => [element, element.inert]));
|
||||
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
|
||||
}
|
||||
|
||||
function releaseBackground() {
|
||||
if (!backgroundInert) return;
|
||||
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
|
||||
backgroundInert = null;
|
||||
}
|
||||
|
||||
function readinessCounts(readiness) {
|
||||
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
|
||||
@@ -35,9 +55,10 @@
|
||||
const readiness = await options.getReadiness();
|
||||
let available = 0;
|
||||
let complete = 0;
|
||||
steps.forEach(([name, button, status]) => {
|
||||
steps.forEach(([name, button, status, _action, defaultActionLabel]) => {
|
||||
const step = readiness[name];
|
||||
status.textContent = step.detail;
|
||||
button.textContent = step.actionLabel || defaultActionLabel;
|
||||
button.hidden = step.state === 'complete' || step.state === 'unavailable';
|
||||
button.disabled = step.state === 'pending';
|
||||
if (step.state !== 'unavailable') available += 1;
|
||||
@@ -56,17 +77,31 @@
|
||||
ownsDetour = options.timerView?.beginDetour?.('device-setup')?.reason === 'device-setup';
|
||||
}
|
||||
await render();
|
||||
containBackground();
|
||||
options.sheet.hidden = false;
|
||||
if (options.history && !options.history.state?.deviceSetup) {
|
||||
options.history.pushState({...options.history.state, deviceSetup:true}, '');
|
||||
}
|
||||
options.closeButton.focus();
|
||||
}
|
||||
|
||||
function close(resume = true) {
|
||||
function finishClose(resume = true) {
|
||||
options.sheet.hidden = true;
|
||||
releaseBackground();
|
||||
if (resume && ownsDetour) options.timerView?.finishDetour?.();
|
||||
ownsDetour = false;
|
||||
trigger?.focus?.();
|
||||
}
|
||||
|
||||
function close(resume = true) {
|
||||
if (options.history?.state?.deviceSetup) {
|
||||
pendingResume = resume;
|
||||
options.history.back();
|
||||
return;
|
||||
}
|
||||
finishClose(resume);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
options.launcher.addEventListener('click', open);
|
||||
options.promptLauncher?.addEventListener('click', open);
|
||||
@@ -82,7 +117,28 @@
|
||||
if (event.target === options.sheet) close();
|
||||
});
|
||||
options.escapeTarget.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !options.sheet.hidden) close();
|
||||
if (event.key === 'Escape' && !options.sheet.hidden) {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
}
|
||||
if (event.key !== 'Tab' || options.sheet.hidden) return;
|
||||
const controls = focusableControls();
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && event.target === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && event.target === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
options.historyTarget?.addEventListener('popstate', event => {
|
||||
if (!options.sheet.hidden && !event.state?.deviceSetup) {
|
||||
finishClose(pendingResume);
|
||||
pendingResume = true;
|
||||
}
|
||||
});
|
||||
steps.forEach(([_name, button, _status, action]) => {
|
||||
button.addEventListener('click', async () => {
|
||||
@@ -105,6 +161,7 @@ if (typeof module === 'object' && module.exports) {
|
||||
|
||||
function mountMobileDeviceSetup(options) {
|
||||
const qs = selector => options.document.querySelector(selector);
|
||||
const window = options.window || options.document.defaultView;
|
||||
return createMobileDeviceSetup({
|
||||
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
|
||||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||
@@ -122,6 +179,11 @@ function mountMobileDeviceSetup(options) {
|
||||
timerView:options.timerView,
|
||||
isMobile:options.isMobile || (() => innerWidth <= 600),
|
||||
escapeTarget:options.document,
|
||||
history:window?.history,
|
||||
historyTarget:window,
|
||||
backgroundElements:[
|
||||
qs('header'), qs('main'), qs('#mobile-task-dock'),
|
||||
].filter(Boolean),
|
||||
getReadiness:() => ({
|
||||
install:options.installApp.state(),
|
||||
offline:!options.offlineAvailable()
|
||||
@@ -130,11 +192,7 @@ function mountMobileDeviceSetup(options) {
|
||||
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
|
||||
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
|
||||
protection:options.storageProtectionReadiness(),
|
||||
push:qs('#push-updates').disabled
|
||||
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
|
||||
: qs('#push-updates').checked
|
||||
? {state:'complete', detail:'New update notifications are enabled.'}
|
||||
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
|
||||
push:options.notificationReadiness(),
|
||||
deadline:options.deadlineReadiness(),
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileFirstTask = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileFirstTask(options) {
|
||||
const doc = typeof document !== 'undefined' ? document : null;
|
||||
const win = typeof window !== 'undefined' ? window : null;
|
||||
options = Object.assign({
|
||||
storage: typeof localStorage !== 'undefined' ? localStorage : null,
|
||||
isOnline: () => typeof navigator === 'undefined' || navigator.onLine,
|
||||
mediaQuery: win?.matchMedia('(max-width: 600px)') || {matches:false},
|
||||
eventTarget: win,
|
||||
sheet: doc?.querySelector('#mobile-first-task'),
|
||||
title: doc?.querySelector('#mobile-first-task-title'),
|
||||
findButton: doc?.querySelector('#mobile-first-task-find'),
|
||||
createButton: doc?.querySelector('#mobile-first-task-create'),
|
||||
setupButton: doc?.querySelector('#mobile-first-task-setup'),
|
||||
closeButton: doc?.querySelector('#close-mobile-first-task'),
|
||||
status: doc?.querySelector('#mobile-first-task-status'),
|
||||
isTodayActive: () => false,
|
||||
onFind: () => doc?.querySelector('#find-work')?.click(),
|
||||
onCreate: () => doc?.querySelector('#new-issue')?.click(),
|
||||
onSetup: () => doc?.querySelector('#open-device-setup')?.click(),
|
||||
}, options);
|
||||
const prefix = 'stackchain.first-task.v1:';
|
||||
|
||||
function account() {
|
||||
return String(options.getLogin?.() || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function key() {
|
||||
const login = account();
|
||||
return login ? prefix + login : '';
|
||||
}
|
||||
|
||||
function completed() {
|
||||
const currentKey = key();
|
||||
if (!currentKey) return false;
|
||||
try { return options.storage.getItem(currentKey) === 'complete'; }
|
||||
catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function required() {
|
||||
return Boolean(options.mediaQuery.matches && account() && !options.isTodayActive() && !completed());
|
||||
}
|
||||
|
||||
function markComplete() {
|
||||
const currentKey = key();
|
||||
if (!currentKey) return;
|
||||
try { options.storage.setItem(currentKey, 'complete'); }
|
||||
catch (_error) {}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const online = options.isOnline();
|
||||
const awaitingStart = options.hasWork();
|
||||
options.findButton.disabled = !online;
|
||||
if (options.title) options.title.textContent = awaitingStart ? 'Finish starting your first task' : 'Start your first task';
|
||||
options.findButton.textContent = 'Find & start';
|
||||
options.createButton.textContent = 'Create & start';
|
||||
options.status.textContent = awaitingStart ?
|
||||
'Your task is ready. Start it from Find or create and start another task.' : online ?
|
||||
'Choose a task to claim and start, or create and start one of your own.' :
|
||||
'You are offline. Create a task now and it will stay in Drafts until you reconnect.';
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!required()) return false;
|
||||
render();
|
||||
if (!options.sheet.open) options.sheet.showModal();
|
||||
(options.findButton.disabled ? options.createButton : options.findButton).focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (account() && options.isTodayActive() && !completed()) {
|
||||
markComplete();
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
return 'completed';
|
||||
}
|
||||
if (options.sheet.open) render();
|
||||
if (required() && options.hasWork()) return 'awaiting-start';
|
||||
return required() ? 'required' : 'inactive';
|
||||
}
|
||||
|
||||
function handoff(callback, requiresOnline = false) {
|
||||
if (requiresOnline && !options.isOnline()) return;
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
callback();
|
||||
}
|
||||
|
||||
function start() {
|
||||
options.findButton.addEventListener('click', () => handoff(options.onFind, true));
|
||||
options.createButton.addEventListener('click', () => handoff(options.onCreate));
|
||||
options.setupButton.addEventListener('click', () => handoff(options.onSetup));
|
||||
options.closeButton.addEventListener('click', () => {
|
||||
if (options.sheet.open) options.sheet.close();
|
||||
});
|
||||
options.eventTarget?.addEventListener('online', render);
|
||||
options.eventTarget?.addEventListener('offline', render);
|
||||
}
|
||||
|
||||
return {required, open, refresh, render, start};
|
||||
});
|
||||
@@ -12,6 +12,7 @@
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
if (options.getEligibleCount() > 0) return 'plan';
|
||||
if (options.shouldActivate?.()) return 'activate';
|
||||
return 'find';
|
||||
}
|
||||
|
||||
@@ -22,6 +23,7 @@
|
||||
else if (current === 'resume') options.resumeToday();
|
||||
else if (current === 'start') options.startToday();
|
||||
else if (current === 'plan') options.planToday();
|
||||
else if (current === 'activate') options.openActivation();
|
||||
else if (current === 'find') options.findWork();
|
||||
else options.queueLauncher.open(current);
|
||||
return current;
|
||||
|
||||
@@ -2,11 +2,68 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createPushNotifications = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview, onReviewDeadlines,
|
||||
notification, serviceWorker, fetchJson,
|
||||
}) {
|
||||
let configuration = null;
|
||||
let pendingIntent = null;
|
||||
let recoveryPromise = null;
|
||||
|
||||
function blockedReadiness() {
|
||||
return {
|
||||
state:'blocked',
|
||||
detail:'Notifications are blocked. Allow them in browser settings, then check again.',
|
||||
actionLabel:'Check again',
|
||||
};
|
||||
}
|
||||
|
||||
function pendingRecoveryReadiness() {
|
||||
return notification.permission === 'granted'
|
||||
? {state:'blocked', detail:'Notification permission changed. Check again to finish setup.', actionLabel:'Check again'}
|
||||
: blockedReadiness();
|
||||
}
|
||||
|
||||
function notificationReadiness() {
|
||||
if (!configuration?.available || control?.disabled) {
|
||||
return {state:'unavailable', detail:status?.textContent || 'Update notifications are unavailable.'};
|
||||
}
|
||||
if (configuration.subscribed) {
|
||||
return {state:'complete', detail:'New update notifications are enabled.'};
|
||||
}
|
||||
if (pendingIntent === 'updates') return pendingRecoveryReadiness();
|
||||
if (notification.permission === 'denied') return blockedReadiness();
|
||||
return {state:'incomplete', detail:status?.textContent || 'New update notifications are off.'};
|
||||
}
|
||||
|
||||
function renderDeliveryHealth() {
|
||||
const health = Object.values(configuration?.delivery_health || {});
|
||||
const degraded = health.find(item => item?.state === 'degraded');
|
||||
if (testControl) testControl.hidden = !configuration?.subscribed;
|
||||
if (configuration?.subscribed && degraded) {
|
||||
const count = Number(degraded.consecutive_failures || 1);
|
||||
status.textContent = `Update notifications need attention after ${count} failed ${count === 1 ? 'delivery' : 'deliveries'}. Send a test notification.`;
|
||||
return;
|
||||
}
|
||||
status.textContent = configuration?.subscribed
|
||||
? 'New update notifications enabled for this device.'
|
||||
: 'New update notifications are off for this device.';
|
||||
}
|
||||
|
||||
async function testDelivery() {
|
||||
if (!testControl) return;
|
||||
testControl.disabled = true;
|
||||
status.textContent = 'Sending a test notification…';
|
||||
try {
|
||||
await fetchJson('api/v1/push-subscription/test', {method:'POST'});
|
||||
configuration.delivery_health = {unread:{state:'healthy',consecutive_failures:0}};
|
||||
status.textContent = 'Test delivered. Update notifications are working on this device.';
|
||||
} catch (error) {
|
||||
status.textContent = 'Test delivery failed. Check your connection, then try again.';
|
||||
} finally {
|
||||
testControl.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formattedHour(value) {
|
||||
return `${String(Number(value)).padStart(2, '0')}:00`;
|
||||
@@ -48,6 +105,7 @@
|
||||
if (!configuration?.available || deadlineControl?.disabled) {
|
||||
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
|
||||
}
|
||||
if (pendingIntent === 'deadline') return pendingRecoveryReadiness();
|
||||
return configuration.deadline_enabled
|
||||
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
|
||||
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
|
||||
@@ -67,16 +125,22 @@
|
||||
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
||||
await subscription?.unsubscribe?.();
|
||||
control.checked = false;
|
||||
if (testControl) testControl.hidden = true;
|
||||
if (deadlineControl) deadlineControl.checked = false;
|
||||
configuration.subscribed = false;
|
||||
configuration.deadline_enabled = false;
|
||||
pendingIntent = null;
|
||||
status.textContent = 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||
}
|
||||
|
||||
async function ensureSubscription() {
|
||||
const permission = await notification.requestPermission();
|
||||
const permission = notification.permission === 'granted'
|
||||
? 'granted'
|
||||
: await notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
control.checked = false;
|
||||
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
|
||||
status.textContent = blockedReadiness().detail;
|
||||
return null;
|
||||
}
|
||||
const registration = await serviceWorker.ready;
|
||||
@@ -95,11 +159,15 @@
|
||||
control.checked = true;
|
||||
status.textContent = 'New update notifications enabled for this device.';
|
||||
configuration.subscribed = true;
|
||||
if (testControl) testControl.hidden = false;
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
await ensureSubscription();
|
||||
pendingIntent = 'updates';
|
||||
const subscription = await ensureSubscription();
|
||||
if (subscription) pendingIntent = null;
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async function change() {
|
||||
@@ -122,6 +190,7 @@
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (deadlineControl.checked) pendingIntent = 'deadline';
|
||||
if (deadlineControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (deadlineControl.checked && !subscription) {
|
||||
deadlineControl.checked = false;
|
||||
@@ -140,6 +209,7 @@
|
||||
configuration.reminder_hour = reminderHour;
|
||||
configuration.reminder_days = reminderDays;
|
||||
configuration.timezone = timezone;
|
||||
pendingIntent = null;
|
||||
deadlineStatus.textContent = deadlineControl.checked
|
||||
? enabledDeadlineText(reminderHour, reminderDays)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
@@ -160,9 +230,28 @@
|
||||
return changeDeadline();
|
||||
}
|
||||
|
||||
async function recoverPermission(intent = null) {
|
||||
if (!pendingIntent && (intent === 'updates' || intent === 'deadline')) pendingIntent = intent;
|
||||
if (!pendingIntent || notification.permission !== 'granted') return false;
|
||||
if (recoveryPromise) return recoveryPromise;
|
||||
recoveryPromise = (async () => {
|
||||
if (pendingIntent === 'deadline') {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
return Boolean(await enable());
|
||||
})();
|
||||
try {
|
||||
return await recoveryPromise;
|
||||
} finally {
|
||||
recoveryPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!control || !notification || !serviceWorker) return;
|
||||
control.addEventListener('change', change);
|
||||
testControl?.addEventListener('click', testDelivery);
|
||||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
deadlineSnoozeReview?.addEventListener('click', reviewSnoozedDeadlines);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
@@ -176,14 +265,12 @@
|
||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
||||
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
||||
status.textContent = configuration.subscribed
|
||||
? 'New update notifications enabled for this device.'
|
||||
: 'New update notifications are off for this device.';
|
||||
renderDeliveryHealth();
|
||||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
renderDeadlineSnooze();
|
||||
}
|
||||
|
||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
|
||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness, notificationReadiness, recoverPermission};
|
||||
});
|
||||
@@ -19,6 +19,31 @@
|
||||
section, root.document.getElementById(`security-${section}-section`),
|
||||
]));
|
||||
let activityCursor = null;
|
||||
let backgroundInert = null;
|
||||
|
||||
const backgroundElements = ['header', 'main', '#mobile-task-dock']
|
||||
.map(selector => root.document.querySelector?.(selector))
|
||||
.filter(Boolean);
|
||||
const focusableControls = () => [...(devicesSheet?.querySelectorAll?.(
|
||||
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
|
||||
) || [])].filter(control => !control.hidden && !control.disabled);
|
||||
const containBackground = () => {
|
||||
if (!backgroundInert) {
|
||||
backgroundInert = new Map(backgroundElements.map(element => [element, element.inert]));
|
||||
}
|
||||
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
|
||||
};
|
||||
const releaseBackground = () => {
|
||||
if (!backgroundInert) return;
|
||||
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
|
||||
backgroundInert = null;
|
||||
};
|
||||
const finishClose = () => {
|
||||
devicesSheet.hidden = true;
|
||||
releaseBackground();
|
||||
devicesButton?.focus();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const navigate = (section, { history = true } = {}) => {
|
||||
if (!sections[section]) section = 'activity';
|
||||
@@ -43,16 +68,41 @@
|
||||
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;
|
||||
const handleRouteChange = section => {
|
||||
if (section && !devicesSheet.hidden) {
|
||||
containBackground();
|
||||
navigate(section, { history: false });
|
||||
return;
|
||||
}
|
||||
if (!devicesSheet.hidden) {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
onClose();
|
||||
finishClose();
|
||||
}
|
||||
};
|
||||
root.addEventListener?.('popstate', event => {
|
||||
handleRouteChange(event.state?.stackchainSecuritySection);
|
||||
});
|
||||
root.addEventListener?.('hashchange', () => {
|
||||
handleRouteChange(root.history?.state?.stackchainSecuritySection);
|
||||
});
|
||||
root.document.addEventListener?.('keydown', event => {
|
||||
if (devicesSheet.hidden) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||
else finishClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = focusableControls();
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && event.target === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && event.target === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -81,7 +131,8 @@
|
||||
revoke.type = 'button';
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
if (device.current && boundary.requestSignOut) await boundary.requestSignOut(revoke);
|
||||
else if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) {
|
||||
await Promise.all([renderDevices(), renderSecurityActivity()]);
|
||||
}
|
||||
@@ -224,6 +275,7 @@
|
||||
const open = () => {
|
||||
if (!devicesSheet) return;
|
||||
onOpen();
|
||||
containBackground();
|
||||
devicesSheet.hidden = false;
|
||||
root.document.getElementById('close-active-devices')?.focus();
|
||||
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });
|
||||
|
||||
@@ -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-v122';
|
||||
const CACHE = 'stackchain-dashboard-shell-v123';
|
||||
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;
|
||||
@@ -15,6 +15,7 @@ const SHELL = [
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/sign-out-review.js',
|
||||
BASE + 'static/feature-loader.js',
|
||||
BASE + 'static/workspace-bootstrap.js',
|
||||
BASE + 'static/conversation-action-hydrator.js',
|
||||
@@ -112,6 +113,7 @@ const SHELL = [
|
||||
BASE + 'static/live-data-status.js',
|
||||
BASE + 'static/mobile-today-command-bar.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-first-task.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/mobile-delivery-recovery.js',
|
||||
|
||||
+22
-14
@@ -38,9 +38,25 @@
|
||||
boundary.startActivityHeartbeat();
|
||||
boundary.startReconnectResume();
|
||||
const button = root.document.getElementById('sign-out');
|
||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
||||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
||||
const allButton = root.document.getElementById('sign-out-all');
|
||||
let review;
|
||||
const features = root.createFeatureLoader({
|
||||
document: root.document,
|
||||
urls: {
|
||||
'sign-out': root.document.querySelector('meta[name="stackchain-feature-sign-out"]')?.content || '',
|
||||
'security-center': root.document.querySelector('meta[name="stackchain-feature-security-center"]')?.content || '',
|
||||
},
|
||||
});
|
||||
const openReview = (mode, source) => features.run(
|
||||
'sign-out', { trigger: source }, () => {
|
||||
if (!review) review = root.createSignOutReview.mount(boundary);
|
||||
review.show(mode, source);
|
||||
},
|
||||
);
|
||||
[[button, 'current'], [allButton, 'all']].forEach(([source, mode]) =>
|
||||
source?.addEventListener('click', () => openReview(mode, source))
|
||||
);
|
||||
boundary.requestSignOut = source => openReview('current', source || button);
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
@@ -51,14 +67,6 @@
|
||||
root.stackchainTodayTimerView?.beginDetour?.('security-center');
|
||||
}
|
||||
};
|
||||
const securityFeatures = root.createFeatureLoader({
|
||||
document: root.document,
|
||||
urls: {
|
||||
'security-center': root.document.querySelector(
|
||||
'meta[name="stackchain-feature-security-center"]'
|
||||
)?.content || '',
|
||||
},
|
||||
});
|
||||
let loadingSecurityCenter = false;
|
||||
const openSecurityCenter = async () => {
|
||||
if (loadingSecurityCenter) return;
|
||||
@@ -67,7 +75,7 @@
|
||||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
try {
|
||||
await securityFeatures.run('security-center', {
|
||||
await features.run('security-center', {
|
||||
trigger: devicesButton,
|
||||
status: devicesStatus,
|
||||
retryLabel: 'Tap Active devices to retry.',
|
||||
@@ -607,8 +615,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function signOutAllDevices() {
|
||||
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
||||
async function signOutAllDevices({ reviewed = false } = {}) {
|
||||
const confirmed = reviewed || confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
||||
if (!confirmed) return false;
|
||||
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Could not sign out all devices');
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else {
|
||||
root.createSignOutReview = factory;
|
||||
root.createSignOutReview.mount = boundary => {
|
||||
const document = root.document;
|
||||
const review = factory({
|
||||
sheet: document.getElementById('sign-out-review-sheet'),
|
||||
heading: document.getElementById('sign-out-review-heading'),
|
||||
summary: document.getElementById('sign-out-review-summary'),
|
||||
warning: document.getElementById('sign-out-review-warning'),
|
||||
cancelButton: document.getElementById('cancel-sign-out'),
|
||||
confirmButton: document.getElementById('confirm-sign-out'),
|
||||
escapeTarget: document,
|
||||
historyTarget: root,
|
||||
history: root.history,
|
||||
backgroundTargets: Array.from(document.querySelectorAll('body > :not(#sign-out-review-sheet)')),
|
||||
getActiveElement: () => document.activeElement,
|
||||
localStorage: root.localStorage,
|
||||
sessionStorage: root.sessionStorage,
|
||||
privateDatabases: root.stackchainPrivateDatabases,
|
||||
inspectPrivateDatabases: root.inspectStackchainPrivateDatabases,
|
||||
onConfirm: mode => mode === 'all'
|
||||
? boundary.signOutAllDevices({ reviewed: true })
|
||||
: boundary.signOut(),
|
||||
});
|
||||
review.start();
|
||||
return review;
|
||||
};
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function createSignOutReview(options) {
|
||||
let mode = 'current';
|
||||
let launcher = null;
|
||||
let open = false;
|
||||
let historyEntry = false;
|
||||
|
||||
function ownedItemCount(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;
|
||||
}
|
||||
|
||||
async function show(nextMode, source) {
|
||||
mode = nextMode;
|
||||
launcher = source;
|
||||
const itemCount = ownedItemCount(options.localStorage)
|
||||
+ (options.sessionStorage === options.localStorage ? 0 : ownedItemCount(options.sessionStorage));
|
||||
let inventory = { recordCount: 0, unavailable: true };
|
||||
try {
|
||||
inventory = await options.inspectPrivateDatabases?.(options.privateDatabases) || inventory;
|
||||
} catch (_error) { /* Unknown private work must use the guarded confirmation path. */ }
|
||||
const recordCount = Math.max(0, Number(inventory.recordCount) || 0);
|
||||
options.heading.textContent = mode === 'all' ? 'Review sign out on every device' : 'Review sign out';
|
||||
options.summary.textContent = inventory.unavailable
|
||||
? `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'}; private work status is unknown.`
|
||||
: `This device has ${itemCount} private browser item${itemCount === 1 ? '' : 's'} and ${recordCount} private work record${recordCount === 1 ? '' : 's'}.`;
|
||||
const atRisk = itemCount > 0 || recordCount > 0 || inventory.unavailable;
|
||||
if (mode === 'all') {
|
||||
options.warning.textContent = atRisk
|
||||
? 'Every device will need to sign in again. Private drafts or queued work on this device may not be synced and will be erased.'
|
||||
: 'Every device will need to sign in again. This device will be cleared after its session is revoked.';
|
||||
} else {
|
||||
options.warning.textContent = atRisk
|
||||
? 'Private drafts or queued work may not be synced. Signing out erases them from this device.'
|
||||
: 'Signing out clears this device after the session is revoked.';
|
||||
}
|
||||
options.confirmButton.textContent = atRisk
|
||||
? 'Sign out and erase private work'
|
||||
: (mode === 'all' ? 'Sign out all devices' : 'Sign out');
|
||||
options.sheet.hidden = false;
|
||||
(options.backgroundTargets || []).forEach(target => { target.inert = true; });
|
||||
open = true;
|
||||
options.history?.pushState?.({ stackchainSignOutReview: true }, '');
|
||||
historyEntry = Boolean(options.history?.back);
|
||||
options.cancelButton.focus();
|
||||
}
|
||||
|
||||
function close({ restoreFocus = true } = {}) {
|
||||
if (!open) return;
|
||||
options.sheet.hidden = true;
|
||||
(options.backgroundTargets || []).forEach(target => { target.inert = false; });
|
||||
open = false;
|
||||
if (restoreFocus) launcher?.focus?.();
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (historyEntry) {
|
||||
historyEntry = false;
|
||||
options.history.back();
|
||||
} else close();
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
options.confirmButton.disabled = true;
|
||||
try {
|
||||
await options.onConfirm(mode);
|
||||
close({ restoreFocus: false });
|
||||
} catch (_error) {
|
||||
options.warning.textContent = 'Sign out could not finish clearing private work. Close other Stackchain tabs, then retry.';
|
||||
} finally {
|
||||
options.confirmButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
options.launcher?.addEventListener('click', event => show('current', event.currentTarget || options.launcher));
|
||||
options.allLauncher?.addEventListener('click', event => show('all', event.currentTarget || options.allLauncher));
|
||||
options.cancelButton?.addEventListener('click', dismiss);
|
||||
options.confirmButton?.addEventListener('click', confirm);
|
||||
options.escapeTarget?.addEventListener('keydown', event => {
|
||||
if (!open) return;
|
||||
if (event.key === 'Escape') { event.preventDefault(); dismiss(); return; }
|
||||
if (event.key !== 'Tab') return;
|
||||
const focusable = Array.from(options.sheet.querySelectorAll('button:not([disabled])'));
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = options.getActiveElement?.();
|
||||
if (!event.shiftKey && active === last) { event.preventDefault(); first?.focus(); }
|
||||
if (event.shiftKey && active === first) { event.preventDefault(); last?.focus(); }
|
||||
});
|
||||
options.historyTarget?.addEventListener('popstate', () => {
|
||||
historyEntry = false;
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
return { start, show, close };
|
||||
});
|
||||
+123
-4
@@ -1,6 +1,11 @@
|
||||
function createTodaySummary({ storage = null, getLogin = () => '', share = null, copy = null } = {}) {
|
||||
function createTodaySummary({
|
||||
storage = null, getLogin = () => '', share = null, copy = null,
|
||||
resolveTarget = null, enqueueDurably = null,
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
} = {}) {
|
||||
let draft = null;
|
||||
let draftKey = '';
|
||||
let postPromise = null;
|
||||
|
||||
const storageKey = () => {
|
||||
const login = String(getLogin() || '').trim().toLowerCase();
|
||||
@@ -20,6 +25,7 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
|
||||
...draft,
|
||||
worked:copyRows(draft.worked),
|
||||
tomorrow:copyRows(draft.tomorrow),
|
||||
target:draft.target ? { ...draft.target } : null,
|
||||
} : null;
|
||||
};
|
||||
const persist = () => {
|
||||
@@ -37,8 +43,15 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
|
||||
if (!storage || !key) return null;
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || 'null');
|
||||
const validTarget = Boolean(saved) && (saved.target === null || saved.target === undefined || (saved.target &&
|
||||
typeof saved.target.repository === 'string' && /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(saved.target.repository) &&
|
||||
Number.isInteger(saved.target.number) && saved.target.number > 0 &&
|
||||
['issue', 'pull'].includes(saved.target.kind) && typeof saved.target.title === 'string' &&
|
||||
typeof saved.target.state === 'string'));
|
||||
if (!saved || !validRows(saved.worked, true) || !validRows(saved.tomorrow, false) ||
|
||||
typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000) {
|
||||
typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000 ||
|
||||
!['string', 'undefined'].includes(typeof saved.destination) || !validTarget ||
|
||||
!['string', 'undefined'].includes(typeof saved.operation_id)) {
|
||||
if (saved !== null) storage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
@@ -75,6 +88,9 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
|
||||
tomorrow:(tomorrow || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, false)),
|
||||
include_actuals:false,
|
||||
note:'',
|
||||
destination:'',
|
||||
target:null,
|
||||
operation_id:'',
|
||||
};
|
||||
persist();
|
||||
return snapshot();
|
||||
@@ -107,6 +123,57 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
|
||||
persist();
|
||||
return true;
|
||||
},
|
||||
setDestination(value) {
|
||||
syncAccount();
|
||||
if (!draft) return null;
|
||||
const match = String(value || '').trim().match(/^([a-z0-9_.-]+)\s*\/\s*([a-z0-9_.-]+)\s*#\s*([1-9][0-9]*)$/i);
|
||||
draft.destination = String(value || '').trim().slice(0, 260);
|
||||
draft.target = null;
|
||||
draft.operation_id = '';
|
||||
persist();
|
||||
if (!match) return null;
|
||||
const repository = (match[1] + '/' + match[2]).toLowerCase();
|
||||
return { repository, number:Number(match[3]), label:repository + '#' + Number(match[3]) };
|
||||
},
|
||||
async validateDestination() {
|
||||
syncAccount();
|
||||
const parsed = this.setDestination(draft?.destination || '');
|
||||
if (!parsed) throw new Error('Enter a destination like owner/repo#42.');
|
||||
if (typeof resolveTarget !== 'function') throw new Error('Destination validation is unavailable.');
|
||||
const resolved = await resolveTarget({ repository:parsed.repository, number:parsed.number });
|
||||
if (!resolved || resolved.repository !== parsed.repository || Number(resolved.number) !== parsed.number ||
|
||||
!['issue', 'pull'].includes(resolved.kind)) throw new Error('Choose an exact visible issue or pull request.');
|
||||
draft.target = {
|
||||
repository:parsed.repository, number:parsed.number, kind:resolved.kind,
|
||||
title:String(resolved.title || '').slice(0, 180), state:String(resolved.state || ''),
|
||||
};
|
||||
draft.operation_id = String(createOperationId()).slice(0, 128);
|
||||
persist();
|
||||
return { ...draft.target };
|
||||
},
|
||||
postSummary() {
|
||||
if (postPromise) return postPromise;
|
||||
syncAccount();
|
||||
const body = this.text();
|
||||
if (!body) throw new Error('Select at least one summary item or add a note.');
|
||||
if (!draft?.target || !draft.operation_id) throw new Error('Validate an exact Gitea destination first.');
|
||||
if (typeof enqueueDurably !== 'function') throw new Error('Gitea posting is unavailable.');
|
||||
const target = { ...draft.target };
|
||||
const message = {
|
||||
kind:'search-reply', repository:target.repository, number:target.number,
|
||||
targetKind:target.kind, body, operationId:draft.operation_id,
|
||||
};
|
||||
postPromise = (async () => {
|
||||
try {
|
||||
const admission = await enqueueDurably(message);
|
||||
discard();
|
||||
return { status:'queued', durability:admission?.durability || 'foreground-only', target };
|
||||
} finally {
|
||||
postPromise = null;
|
||||
}
|
||||
})();
|
||||
return postPromise;
|
||||
},
|
||||
discard,
|
||||
text() {
|
||||
syncAccount();
|
||||
@@ -162,6 +229,10 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) {
|
||||
qs('#today-summary-include-actuals').checked = draft.include_actuals;
|
||||
qs('#today-summary-note').value = draft.note;
|
||||
qs('#today-summary-preview').textContent = summary.text();
|
||||
qs('#today-summary-destination').value = draft.destination || '';
|
||||
qs('#today-summary-target').textContent = draft.target ?
|
||||
(draft.target.repository + '#' + draft.target.number + ' · ' + draft.target.kind + ' · ' + draft.target.title) : '';
|
||||
qs('#post-today-summary').disabled = !draft.target;
|
||||
qs('#today-summary-sheet').querySelectorAll('[data-summary-identity]').forEach(input => {
|
||||
input.addEventListener('change', () => {
|
||||
summary.choose(input.dataset.summarySection, input.dataset.summaryIdentity, input.checked);
|
||||
@@ -202,6 +273,34 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDestination(button) {
|
||||
button.disabled = true;
|
||||
qs('#today-summary-status').textContent = 'Checking destination…';
|
||||
try {
|
||||
await summary.validateDestination();
|
||||
render();
|
||||
qs('#today-summary-status').textContent = 'Destination confirmed. Review it, then post once.';
|
||||
} catch (error) {
|
||||
qs('#today-summary-status').textContent = error.message || 'Destination could not be confirmed.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function postDraft(button) {
|
||||
button.disabled = true;
|
||||
qs('#today-summary-status').textContent = 'Saving comment for delivery…';
|
||||
try {
|
||||
const result = await summary.postSummary();
|
||||
qs('#my-work-action-status').textContent = 'Today summary queued for ' +
|
||||
result.target.repository + '#' + result.target.number + '.';
|
||||
close();
|
||||
} catch (error) {
|
||||
qs('#today-summary-status').textContent = error.message || 'Summary could not be queued. Your review is unchanged.';
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bind() {
|
||||
qs('#close-today-summary').addEventListener('click', close);
|
||||
qs('#discard-today-summary').addEventListener('click', () => { summary.discard(); close(); });
|
||||
@@ -212,21 +311,41 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) {
|
||||
summary.setNote(event.currentTarget.value);
|
||||
qs('#today-summary-preview').textContent = summary.text();
|
||||
});
|
||||
qs('#today-summary-destination').addEventListener('input', event => {
|
||||
summary.setDestination(event.currentTarget.value);
|
||||
qs('#today-summary-target').textContent = '';
|
||||
qs('#post-today-summary').disabled = true;
|
||||
});
|
||||
qs('#validate-today-summary-destination').addEventListener('click', event => validateDestination(event.currentTarget));
|
||||
qs('#post-today-summary').addEventListener('click', event => postDraft(event.currentTarget));
|
||||
qs('#share-today-summary').addEventListener('click', event => shareDraft(event.currentTarget));
|
||||
}
|
||||
|
||||
return {
|
||||
open, close, render, bind, shareDraft,
|
||||
open, close, render, bind, shareDraft, validateDestination, postDraft,
|
||||
resume() { if (summary.restore()) show(); },
|
||||
};
|
||||
}
|
||||
|
||||
function setupTodaySummary({ qs, escapeHtml, getLogin }) {
|
||||
function setupTodaySummary({ qs, escapeHtml, getLogin, resolveTarget, enqueueDurably, fetchJson }) {
|
||||
const targetResolver = resolveTarget || (async target => {
|
||||
if (typeof fetchJson !== 'function') throw new Error('Destination validation is unavailable.');
|
||||
const item = await fetchJson('api/v1/repos/' + target.repository + '/issues/' + target.number +
|
||||
'/preview?kind=issue');
|
||||
if (String(item.repository || '').toLowerCase() !== target.repository ||
|
||||
Number(item.number) !== target.number || !['issue', 'pull'].includes(item.kind)) {
|
||||
throw new Error('That exact issue or pull request is not visible to this account.');
|
||||
}
|
||||
return { repository:target.repository, number:target.number, kind:item.kind,
|
||||
title:item.title || '', state:item.state || '' };
|
||||
});
|
||||
const summary = createTodaySummary({
|
||||
storage:localStorage,
|
||||
getLogin,
|
||||
share:typeof navigator.share === 'function' ? payload => navigator.share(payload) : null,
|
||||
copy:typeof navigator.clipboard?.writeText === 'function' ? text => navigator.clipboard.writeText(text) : null,
|
||||
resolveTarget:targetResolver,
|
||||
enqueueDurably,
|
||||
});
|
||||
const view = createTodaySummaryView({ summary, qs, escapeHtml });
|
||||
view.bind();
|
||||
|
||||
@@ -57,7 +57,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
const validDetour = state => {
|
||||
const pending = state.detour_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup', 'security-center'].includes(pending.reason) ?
|
||||
typeof pending.resume === 'boolean' && ['find', 'queues', 'insights', 'device-setup', 'security-center', 'live-data-status'].includes(pending.reason) ?
|
||||
{ identity:pending.identity, resume:pending.resume, reason:pending.reason } : null;
|
||||
};
|
||||
const validBreak = state => {
|
||||
@@ -114,7 +114,8 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
activate(identity) {
|
||||
if (!key() || typeof identity !== 'string' || !identity) return false;
|
||||
const state = read();
|
||||
if (state.active_identity === identity && state.entries[identity]?.running) return true;
|
||||
if (state.active_identity === identity &&
|
||||
(state.entries[identity]?.running || validBreak(state)?.identity === identity)) return true;
|
||||
settle(state);
|
||||
state.active_identity = identity;
|
||||
const entry = state.entries[identity] || { elapsed_ms:0, started_at:null, running:false };
|
||||
@@ -289,7 +290,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
return write(state) ? { identity:pending.identity, resumed } : null;
|
||||
},
|
||||
beginDetour(reason) {
|
||||
if (!['find', 'queues', 'insights', 'device-setup', 'security-center'].includes(reason)) return null;
|
||||
if (!['find', 'queues', 'insights', 'device-setup', 'security-center', 'live-data-status'].includes(reason)) return null;
|
||||
const state = read();
|
||||
const existing = validDetour(state);
|
||||
if (existing) return existing;
|
||||
|
||||
@@ -1,41 +1,75 @@
|
||||
async function loadWorkspace({ document, window = null, createLoader = createFeatureLoader }) {
|
||||
async function loadWorkspace({
|
||||
document,
|
||||
window = null,
|
||||
createLoader = createFeatureLoader,
|
||||
schedule = callback => setTimeout(callback,750),
|
||||
}) {
|
||||
let cameOnline = false;
|
||||
let replayed = false;
|
||||
let retryInFlight = null;
|
||||
const captureOnline = () => { cameOnline = true; };
|
||||
window?.addEventListener('online', captureOnline);
|
||||
const status = document.querySelector('#my-work-action-status');
|
||||
const retryButton = document.querySelector('#retry-workspace');
|
||||
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();
|
||||
},
|
||||
const urls = {'today-timer':url};
|
||||
const loader = createLoader({document, urls});
|
||||
|
||||
let attempts = 0;
|
||||
const load = () => {
|
||||
if (attempts++) urls['today-timer'] = url + '?retry=' + attempts;
|
||||
return loader.load('today-timer');
|
||||
};
|
||||
const waitForRecovery = () => new Promise(resolve => {
|
||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||
if (retryButton) {
|
||||
retryButton.hidden = retryButton.disabled = false;
|
||||
}
|
||||
|
||||
const recover = () => {
|
||||
if (retryInFlight) return retryInFlight;
|
||||
if (retryButton) retryButton.disabled = true;
|
||||
retryInFlight = load().then(() => {
|
||||
window?.removeEventListener('online', recover);
|
||||
resolve();
|
||||
}).catch(() => {
|
||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
||||
if (retryButton) retryButton.disabled = false;
|
||||
}).finally(() => {
|
||||
retryInFlight = null;
|
||||
});
|
||||
return retryInFlight;
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (window?.location?.reload) {
|
||||
if (cameOnline) window.location.reload();
|
||||
else window.addEventListener('online', () => window.location.reload(), { once: true });
|
||||
window?.addEventListener('online', recover);
|
||||
retryButton?.addEventListener('click', recover);
|
||||
});
|
||||
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
await new Promise(resolve => schedule(resolve));
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
await waitForRecovery();
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (retryButton) retryButton.hidden = true;
|
||||
if (status) status.textContent = '';
|
||||
return {
|
||||
replayOnline(callback) {
|
||||
if (replayed) return;
|
||||
replayed = true;
|
||||
window?.removeEventListener('online', captureOnline);
|
||||
if (cameOnline) callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
|
||||
@@ -27,14 +27,15 @@ FEATURE_SOURCES = {
|
||||
),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js", "static/release-receipt.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"sign-out": ("static/private-data-inventory.js", "static/sign-out-review.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/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/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.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-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
||||
"static/conversation.js", "static/widgets.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/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.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-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/detail-defer.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",
|
||||
|
||||
+51
-4
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, private_state_encryption_key
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -29,12 +30,17 @@ class IdempotencyLedger:
|
||||
max_entries: int,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
clock: Callable[[], float] = time.time,
|
||||
encryption_key: bytes | None = None,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.max_entries = max_entries
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
self.clock = clock
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
store="idempotency-ledger",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
@@ -67,6 +73,17 @@ class IdempotencyLedger:
|
||||
def _fingerprint(value: tuple[Any, ...]) -> str:
|
||||
return json.dumps(value, separators=(",", ":"), sort_keys=True)
|
||||
|
||||
def _seal(self, value: str, *, key: str, field: str) -> str:
|
||||
return self._cipher.seal(value, binding=f"{key}:{field}")
|
||||
|
||||
def _open(self, payload: str, *, key: str, field: str) -> tuple[str, bool]:
|
||||
value, legacy = self._cipher.open(payload, binding=f"{key}:{field}")
|
||||
if legacy:
|
||||
return json.dumps(value, separators=(",", ":"), sort_keys=True), True
|
||||
if not isinstance(value, str):
|
||||
raise RuntimeError("idempotency ledger payload is invalid")
|
||||
return value, legacy
|
||||
|
||||
def reserve(self, key: str, fingerprint: tuple[Any, ...]) -> Reservation:
|
||||
encoded = self._fingerprint(fingerprint)
|
||||
now = self.clock()
|
||||
@@ -84,10 +101,40 @@ class IdempotencyLedger:
|
||||
(key,),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
if row[0] != encoded:
|
||||
stored_fingerprint, legacy_fingerprint = self._open(
|
||||
row[0], key=key, field="fingerprint"
|
||||
)
|
||||
if legacy_fingerprint:
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET fingerprint = ? "
|
||||
"WHERE key = ? AND fingerprint = ?",
|
||||
(
|
||||
self._seal(
|
||||
stored_fingerprint, key=key, field="fingerprint"
|
||||
),
|
||||
key,
|
||||
row[0],
|
||||
),
|
||||
)
|
||||
if stored_fingerprint != encoded:
|
||||
return Reservation("conflict")
|
||||
if row[1] == "completed":
|
||||
return Reservation("completed", json.loads(row[2]))
|
||||
stored_response, legacy_response = self._open(
|
||||
row[2], key=key, field="response"
|
||||
)
|
||||
if legacy_response:
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET response_json = ? "
|
||||
"WHERE key = ? AND response_json = ?",
|
||||
(
|
||||
self._seal(
|
||||
stored_response, key=key, field="response"
|
||||
),
|
||||
key,
|
||||
row[2],
|
||||
),
|
||||
)
|
||||
return Reservation("completed", json.loads(stored_response))
|
||||
if row[3] <= now - self.ttl_seconds:
|
||||
return Reservation("uncertain")
|
||||
return Reservation("pending")
|
||||
@@ -110,7 +157,7 @@ class IdempotencyLedger:
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency_operations "
|
||||
"(key, fingerprint, status, created_at) VALUES (?, ?, 'pending', ?)",
|
||||
(key, encoded, now),
|
||||
(key, self._seal(encoded, key=key, field="fingerprint"), now),
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
|
||||
@@ -129,7 +176,7 @@ class IdempotencyLedger:
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET status = 'completed', "
|
||||
"response_json = ?, completed_at = ? WHERE key = ?",
|
||||
(encoded, self.clock(), key),
|
||||
(self._seal(encoded, key=key, field="response"), self.clock(), key),
|
||||
)
|
||||
except sqlite3.OperationalError as exc:
|
||||
if "locked" in str(exc).lower() or "busy" in str(exc).lower():
|
||||
|
||||
+52
@@ -3,6 +3,7 @@ import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
@@ -55,6 +56,7 @@ from src.push_notifications import (
|
||||
PushConfiguration,
|
||||
dispatch_deadline_reminders,
|
||||
dispatch_unread_updates,
|
||||
send_web_push,
|
||||
)
|
||||
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
|
||||
from src.push_subscription_store import build_push_subscription_store
|
||||
@@ -2159,6 +2161,9 @@ async def push_status(request: Request):
|
||||
preferences = await asyncio.to_thread(
|
||||
_push_subscription_store.deadline_preferences, device_id, now=time.time()
|
||||
)
|
||||
delivery_health = await asyncio.to_thread(
|
||||
_push_subscription_store.delivery_health, device_id
|
||||
)
|
||||
return {
|
||||
"available": configuration.enabled,
|
||||
"subscribed": subscribed,
|
||||
@@ -2168,6 +2173,7 @@ async def push_status(request: Request):
|
||||
"reminder_hour": preferences["reminder_hour"],
|
||||
"reminder_days": preferences["reminder_days"],
|
||||
"snoozed_until": preferences["snoozed_until"],
|
||||
"delivery_health": delivery_health,
|
||||
}
|
||||
|
||||
|
||||
@@ -2221,6 +2227,52 @@ async def unsubscribe_push(request: Request):
|
||||
return {"subscribed": False}
|
||||
|
||||
|
||||
@app.post("/api/v1/push-subscription/test")
|
||||
async def test_push_notification(request: Request):
|
||||
configuration = _push_configuration()
|
||||
if not configuration.enabled:
|
||||
raise HTTPException(status_code=503, detail="Push notifications are not configured")
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
request.state.dashboard_session
|
||||
)
|
||||
subscription = await asyncio.to_thread(
|
||||
_push_subscription_store.subscription_for_session, device_id
|
||||
)
|
||||
if subscription is None:
|
||||
raise HTTPException(status_code=409, detail="Enable device notifications first")
|
||||
payload = json.dumps({
|
||||
"title": "Stackchain notifications are working",
|
||||
"body": "This device can receive private work alerts.",
|
||||
"route": "#/device-setup",
|
||||
"tag": "stackchain-push-test",
|
||||
}, separators=(",", ":"))
|
||||
try:
|
||||
await send_web_push(subscription, payload, configuration)
|
||||
except Exception as error:
|
||||
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(_push_subscription_store.delete_session, device_id)
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Re-enable notifications for this device"
|
||||
) from error
|
||||
reason = "timeout" if isinstance(error, (asyncio.TimeoutError, TimeoutError)) else "provider"
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.mark_delivery_failed,
|
||||
device_id,
|
||||
"unread",
|
||||
reason,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Test notification delivery failed",
|
||||
headers={"Retry-After": "1"},
|
||||
) from error
|
||||
await asyncio.to_thread(
|
||||
_push_subscription_store.mark_delivery_succeeded, device_id, "unread"
|
||||
)
|
||||
return {"delivery_state": "healthy", "delivered": True}
|
||||
|
||||
|
||||
@app.put("/api/v1/push-subscription/deadlines")
|
||||
async def update_deadline_reminders(payload: DeadlineReminderPayload, request: Request):
|
||||
device_id = await dashboard_auth.session_management_id(
|
||||
|
||||
@@ -57,6 +57,12 @@ class _PinnedHTTPSAdapter(requests.adapters.HTTPAdapter):
|
||||
return host, tls
|
||||
|
||||
|
||||
def _delivery_failure_reason(error: Exception) -> str:
|
||||
if isinstance(error, (asyncio.TimeoutError, TimeoutError)):
|
||||
return "timeout"
|
||||
return "provider"
|
||||
|
||||
|
||||
async def send_web_push(
|
||||
subscription: dict,
|
||||
payload: str,
|
||||
@@ -214,10 +220,20 @@ async def dispatch_unread_updates(
|
||||
await asyncio.to_thread(
|
||||
store.delete_session, delivery.session_id
|
||||
)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
delivery.session_id,
|
||||
"unread",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
can_send_digest = False
|
||||
# Leave this device's transient failures unseen for a later
|
||||
# poll instead of paying the endpoint deadline repeatedly.
|
||||
break
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, delivery.session_id, "unread"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivered,
|
||||
delivery.session_id,
|
||||
@@ -262,12 +278,21 @@ async def dispatch_unread_updates(
|
||||
store.delete_session, delivery.session_id
|
||||
)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
delivery.session_id,
|
||||
"unread",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_digest_pending,
|
||||
delivery.session_id,
|
||||
overflow_revisions,
|
||||
)
|
||||
return count
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, delivery.session_id, "unread"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivered,
|
||||
delivery.session_id,
|
||||
@@ -279,10 +304,19 @@ async def dispatch_unread_updates(
|
||||
async def dispatch_device_safely(delivery) -> int:
|
||||
try:
|
||||
return await dispatch_device(delivery)
|
||||
except Exception:
|
||||
except Exception as error:
|
||||
# Device-specific validation, persistence, or provider failures
|
||||
# must not cancel healthy siblings. Leave any uncheckpointed
|
||||
# revisions unseen so a later poll can retry them.
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
delivery.session_id,
|
||||
"unread",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
counts = await asyncio.gather(
|
||||
@@ -461,9 +495,20 @@ async def _dispatch_deadline_reminders_unlocked(
|
||||
)
|
||||
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
|
||||
except Exception as error:
|
||||
if isinstance(error, UnsafePushEndpoint):
|
||||
status = getattr(getattr(error, "response", None), "status_code", None)
|
||||
if isinstance(error, UnsafePushEndpoint) or status in {404, 410}:
|
||||
await asyncio.to_thread(store.delete_session, device.session_id)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_failed,
|
||||
device.session_id,
|
||||
"deadline",
|
||||
_delivery_failure_reason(error),
|
||||
)
|
||||
return 0
|
||||
await asyncio.to_thread(
|
||||
store.mark_delivery_succeeded, device.session_id, "deadline"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
store.mark_deadline_reminder_delivered, device.session_id, local_day
|
||||
)
|
||||
|
||||
@@ -49,6 +49,9 @@ class DisabledPushSubscriptionStore:
|
||||
def is_subscribed(self, session_id: str) -> bool:
|
||||
return False
|
||||
|
||||
def subscription_for_session(self, session_id: str) -> dict | None:
|
||||
return None
|
||||
|
||||
def deadline_preferences(
|
||||
self, session_id: str, *, now: float | None = None
|
||||
) -> dict:
|
||||
@@ -102,6 +105,15 @@ class DisabledPushSubscriptionStore:
|
||||
def mark_delivered(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def delivery_health(self, *args, **kwargs) -> dict:
|
||||
return {}
|
||||
|
||||
def mark_delivery_failed(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
def mark_delivery_succeeded(self, *args, **kwargs) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def build_push_subscription_store(
|
||||
path: str | Path, *, push_enabled: bool
|
||||
@@ -183,6 +195,17 @@ class PushSubscriptionStore:
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS push_delivery_health (
|
||||
session_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
last_attempted_at REAL NOT NULL,
|
||||
last_succeeded_at REAL,
|
||||
reason TEXT,
|
||||
PRIMARY KEY (session_id, channel),
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
"""
|
||||
)
|
||||
delivery_columns = {
|
||||
@@ -316,6 +339,67 @@ class PushSubscriptionStore:
|
||||
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
|
||||
).fetchone() is not None
|
||||
|
||||
def subscription_for_session(self, session_id: str) -> dict | None:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT subscription_json FROM push_subscriptions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return self._open_subscription(session_id, row[0]) if row else None
|
||||
|
||||
def delivery_health(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT channel, consecutive_failures, last_attempted_at,
|
||||
last_succeeded_at, reason
|
||||
FROM push_delivery_health WHERE session_id = ? ORDER BY channel""",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
return {
|
||||
row[0]: {
|
||||
"state": "degraded" if row[1] else "healthy",
|
||||
"consecutive_failures": row[1],
|
||||
"last_attempted_at": row[2],
|
||||
"last_succeeded_at": row[3],
|
||||
"reason": row[4],
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
|
||||
def mark_delivery_failed(
|
||||
self, session_id: str, channel: str, reason: str, *, now: float | None = None
|
||||
) -> None:
|
||||
attempted_at = time.time() if now is None else now
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_delivery_health(
|
||||
session_id, channel, consecutive_failures, last_attempted_at, reason
|
||||
) VALUES (?, ?, 1, ?, ?)
|
||||
ON CONFLICT(session_id, channel) DO UPDATE SET
|
||||
consecutive_failures = consecutive_failures + 1,
|
||||
last_attempted_at = excluded.last_attempted_at,
|
||||
reason = excluded.reason""",
|
||||
(session_id, channel, attempted_at, reason),
|
||||
)
|
||||
|
||||
def mark_delivery_succeeded(
|
||||
self, session_id: str, channel: str, *, now: float | None = None
|
||||
) -> None:
|
||||
attempted_at = time.time() if now is None else now
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_delivery_health(
|
||||
session_id, channel, consecutive_failures, last_attempted_at,
|
||||
last_succeeded_at, reason
|
||||
) VALUES (?, ?, 0, ?, ?, NULL)
|
||||
ON CONFLICT(session_id, channel) DO UPDATE SET
|
||||
consecutive_failures = 0,
|
||||
last_attempted_at = excluded.last_attempted_at,
|
||||
last_succeeded_at = excluded.last_succeeded_at,
|
||||
reason = NULL""",
|
||||
(session_id, channel, attempted_at, attempted_at),
|
||||
)
|
||||
|
||||
def set_deadline_preferences(
|
||||
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int,
|
||||
reminder_days: int = 2,
|
||||
|
||||
+34
-11
@@ -1,11 +1,11 @@
|
||||
"""Durable, account-scoped saved Search views."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from src.private_state import connect_private_sqlite
|
||||
from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError, private_state_encryption_key
|
||||
|
||||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
@@ -21,10 +21,21 @@ class SavedSearchConflict(ValueError):
|
||||
|
||||
|
||||
class SavedSearchStore:
|
||||
def __init__(self, path: str | Path, *, limit: int = 20, timeout: float = 1.0):
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
limit: int = 20,
|
||||
timeout: float = 1.0,
|
||||
encryption_key: bytes | None = None,
|
||||
):
|
||||
self.path = Path(path)
|
||||
self.limit = limit
|
||||
self.timeout = timeout
|
||||
self._cipher = PrivateStateCipher(
|
||||
encryption_key if encryption_key is not None else private_state_encryption_key(),
|
||||
store="saved-searches",
|
||||
)
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
@@ -50,19 +61,31 @@ class SavedSearchStore:
|
||||
raise ValueError("login is required")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(row) -> dict:
|
||||
return {"revision": 0, "views": []} if row is None else {
|
||||
"revision": int(row[0]), "views": json.loads(row[1])
|
||||
}
|
||||
def _snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return {"revision": 0, "views": []}, False
|
||||
views, legacy = self._cipher.open(row[1], binding=f"views:{login}")
|
||||
if not isinstance(views, list):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
return {"revision": int(row[0]), "views": views}, legacy
|
||||
|
||||
def _sealed_views(self, login: str, views: list[dict]) -> str:
|
||||
return self._cipher.seal(views, binding=f"views:{login}")
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._login(login)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, views FROM saved_searches WHERE login = ?",
|
||||
(self._login(login),),
|
||||
(login,),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
snapshot, legacy = self._snapshot(row, login)
|
||||
if row is not None and legacy:
|
||||
connection.execute(
|
||||
"UPDATE saved_searches SET views = ? WHERE login = ? AND views = ?",
|
||||
(self._sealed_views(login, snapshot["views"]), login, row[1]),
|
||||
)
|
||||
return snapshot
|
||||
|
||||
def _normalize(self, views: list[dict]) -> list[dict]:
|
||||
if not isinstance(views, list):
|
||||
@@ -113,13 +136,13 @@ class SavedSearchStore:
|
||||
if not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0:
|
||||
raise ValueError("revision is invalid")
|
||||
normalized = self._normalize(views)
|
||||
serialized = json.dumps(normalized, separators=(",", ":"))
|
||||
serialized = self._sealed_views(login, normalized)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, views FROM saved_searches WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
current = self._snapshot(row)
|
||||
current, _legacy = self._snapshot(row, login)
|
||||
if current["revision"] != expected_revision:
|
||||
raise SavedSearchConflict(current)
|
||||
revision = expected_revision + 1
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
@@ -15,6 +16,61 @@ from fake_gitea import AVAILABLE_ISSUES, FakeGiteaServer
|
||||
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "height"), [(390, 844)])
|
||||
def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
||||
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.assigned_issue_numbers = []
|
||||
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
fake_thread.start()
|
||||
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||
|
||||
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.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("First task 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("No assigned work")
|
||||
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
sheet = page.locator("#mobile-first-task")
|
||||
expect(sheet).to_be_visible()
|
||||
expect(page.locator("#mobile-first-task-find")).to_be_focused()
|
||||
for selector in ("#mobile-first-task-find", "#mobile-first-task-create", "#mobile-first-task-setup"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator("#mobile-first-task-find").click()
|
||||
expect(sheet).to_be_hidden()
|
||||
expect(page.locator("#find-work-sheet")).to_be_visible()
|
||||
page.locator("#close-find-work").click()
|
||||
|
||||
context.set_offline(True)
|
||||
page.locator('[data-mobile-task="work"]').click()
|
||||
expect(sheet).to_be_visible()
|
||||
expect(page.locator("#mobile-first-task-find")).to_be_disabled()
|
||||
expect(page.locator("#mobile-first-task-status")).to_contain_text("Create a task now")
|
||||
expect(page.locator("#mobile-first-task-create")).to_be_enabled()
|
||||
expect(page.locator("#mobile-first-task-create")).to_be_focused()
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
@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
|
||||
@@ -147,16 +203,47 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
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")
|
||||
expect(page.locator("body > header")).to_have_attribute("inert", "")
|
||||
expect(page.locator("main")).to_have_attribute("inert", "")
|
||||
expect(dock).to_have_attribute("inert", "")
|
||||
expect(page.locator("#close-device-setup")).to_be_focused()
|
||||
page.keyboard.press("Shift+Tab")
|
||||
expect(page.locator("#clear-private-device-data")).to_be_focused()
|
||||
page.keyboard.press("Tab")
|
||||
expect(page.locator("#close-device-setup")).to_be_focused()
|
||||
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()
|
||||
page.go_back()
|
||||
expect(page.locator("#device-setup-sheet")).to_be_hidden()
|
||||
expect(page.locator("#open-device-setup")).to_be_focused()
|
||||
expect(page.locator("body > header")).not_to_have_attribute("inert", "")
|
||||
expect(page.locator("main")).not_to_have_attribute("inert", "")
|
||||
expect(dock).not_to_have_attribute("inert", "")
|
||||
|
||||
page.locator("#active-devices").click()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_visible()
|
||||
expect(page.locator("#security-activity-section")).to_be_visible()
|
||||
expect(page.locator("body > header")).to_have_attribute("inert", "")
|
||||
expect(page.locator("main")).to_have_attribute("inert", "")
|
||||
expect(dock).to_have_attribute("inert", "")
|
||||
expect(page.locator("#close-active-devices")).to_be_focused()
|
||||
expect(page.locator("#active-devices-status")).to_contain_text("active device")
|
||||
expect(page.locator("#enrolled-passkeys-status")).not_to_contain_text("Loading")
|
||||
page.keyboard.press("Shift+Tab")
|
||||
assert page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const controls = [...document.querySelector('#active-devices-sheet').querySelectorAll(
|
||||
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
|
||||
)].filter(control => !control.hidden);
|
||||
return document.activeElement === controls.at(-1);
|
||||
}
|
||||
"""
|
||||
)
|
||||
page.keyboard.press("Tab")
|
||||
expect(page.locator("#close-active-devices")).to_be_focused()
|
||||
security_nav = page.locator(".security-section-nav")
|
||||
expect(security_nav).to_be_visible()
|
||||
for control in security_nav.locator("button").all():
|
||||
@@ -166,9 +253,15 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
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")
|
||||
expect(page.locator("body > header")).to_have_attribute("inert", "")
|
||||
expect(page.locator("main")).to_have_attribute("inert", "")
|
||||
expect(dock).to_have_attribute("inert", "")
|
||||
page.go_back()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_hidden()
|
||||
expect(page.locator("#active-devices")).to_be_focused()
|
||||
expect(page.locator("body > header")).not_to_have_attribute("inert", "")
|
||||
expect(page.locator("main")).not_to_have_attribute("inert", "")
|
||||
expect(dock).not_to_have_attribute("inert", "")
|
||||
|
||||
assert len(workspace_requests) == 1, workspace_requests
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
@@ -181,6 +274,59 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||
fake_thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_release_artifact_recovers_a_transient_workspace_request_in_place(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}"
|
||||
attempts: 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.goto(origin + "/", wait_until="networkidle")
|
||||
|
||||
def interrupt_once(route):
|
||||
attempts.append(route.request.url)
|
||||
if len(attempts) == 1:
|
||||
route.abort("failed")
|
||||
else:
|
||||
route.continue_()
|
||||
|
||||
page.route(re.compile(r"/feature-today-timer-[^/?]+\.js(?:\?.*)?$"), interrupt_once)
|
||||
page.locator('input[name="device_label"]').fill("Workspace 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.wait_for_timeout(1500)
|
||||
resources = page.evaluate(
|
||||
"performance.getEntriesByType('resource').map(entry => entry.name)"
|
||||
)
|
||||
state = {
|
||||
"attempts": attempts,
|
||||
"resources": resources,
|
||||
"action": page.locator("#my-work-action-status").text_content(),
|
||||
"retry_hidden": page.locator("#retry-workspace").is_hidden(),
|
||||
"work": page.locator("#my-work-status").text_content(),
|
||||
}
|
||||
assert len(attempts) == 2, state
|
||||
assert "2" in (state["work"] or ""), state
|
||||
expect(page.locator("#retry-workspace")).to_be_hidden()
|
||||
expect(page.locator("#my-work-action-status")).not_to_contain_text("Workspace unavailable")
|
||||
assert any("feature-today-timer-" in url and "retry=" in url for url in resources), state
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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 sign-out 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_reviews_private_work_before_mobile_sign_out(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))
|
||||
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
delete_requests: list[str] = []
|
||||
browser_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"])
|
||||
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(
|
||||
"request",
|
||||
lambda request: delete_requests.append(request.url)
|
||||
if request.method == "DELETE" and "/api/v1/session" in request.url
|
||||
else None,
|
||||
)
|
||||
page.goto(origin + "/", wait_until="networkidle")
|
||||
page.locator('input[name="device_label"]').fill("Sign-out 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.release-test-draft', 'unsynced')")
|
||||
|
||||
launcher = page.locator("#sign-out")
|
||||
page.locator("#app-menu-toggle").click()
|
||||
launcher.click()
|
||||
sheet = page.locator("#sign-out-review-sheet")
|
||||
expect(sheet).to_be_visible()
|
||||
expect(page.locator("#sign-out-review-summary")).to_contain_text("private browser item")
|
||||
expect(page.locator("#sign-out-review-warning")).to_contain_text("may not be synced")
|
||||
expect(page.locator("#confirm-sign-out")).to_have_text("Sign out and erase private work")
|
||||
assert page.evaluate("document.querySelector('main').inert") is True
|
||||
panel = page.locator(".sign-out-review-panel").bounding_box()
|
||||
assert panel and panel["height"] <= 844
|
||||
for selector in ("#cancel-sign-out", "#confirm-sign-out"):
|
||||
bounds = page.locator(selector).bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
assert delete_requests == []
|
||||
|
||||
page.keyboard.press("Escape")
|
||||
expect(sheet).to_be_hidden()
|
||||
expect(launcher).to_be_focused()
|
||||
assert page.evaluate("document.querySelector('main').inert") is False
|
||||
assert page.evaluate("localStorage.getItem('stackchain.release-test-draft')") == "unsynced"
|
||||
assert delete_requests == []
|
||||
assert browser_errors == []
|
||||
browser.close()
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
@@ -58,6 +58,9 @@ def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
|
||||
storage:localStorage,getLogin:()=> 'timmy',
|
||||
share:payload=>navigator.share(payload),
|
||||
copy:text=>navigator.clipboard.writeText(text),
|
||||
createOperationId:()=> 'release-summary-op',
|
||||
resolveTarget:async target=>({...target,kind:'issue',title:'Daily status',state:'open'}),
|
||||
enqueueDurably:async message=>{ window.__queuedSummary=message; return {item:message,durability:'background'}; },
|
||||
});
|
||||
const view=createTodaySummaryView({summary,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
|
||||
window.__summaryTest={summary,view};
|
||||
@@ -80,6 +83,42 @@ def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
page.locator("#today-summary-destination").fill("acme/mobile#41")
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
window.__summaryTest.summary.setDestination(document.querySelector('#today-summary-destination').value);
|
||||
return window.__summaryTest.view.validateDestination(document.querySelector('#validate-today-summary-destination'));
|
||||
}
|
||||
"""
|
||||
)
|
||||
expect(page.locator("#today-summary-target")).to_contain_text(
|
||||
"acme/mobile#41 · issue · Daily status"
|
||||
)
|
||||
expect(page.locator("#post-today-summary")).to_be_enabled()
|
||||
page.evaluate(
|
||||
"window.__summaryTest.view.postDraft(document.querySelector('#post-today-summary'))"
|
||||
)
|
||||
expect(sheet).to_be_hidden()
|
||||
assert page.evaluate("window.__queuedSummary") == {
|
||||
"kind": "search-reply",
|
||||
"repository": "acme/mobile",
|
||||
"number": 41,
|
||||
"targetKind": "issue",
|
||||
"body": "Today\n- Ship mobile capture\n\nTomorrow\n- Review release",
|
||||
"operationId": "release-summary-op",
|
||||
}
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
window.__summaryTest.view.open(
|
||||
[{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}],
|
||||
[{identity:'issue:acme/mobile:42:',title:'Review release',context:'acme/mobile#42'}]
|
||||
)
|
||||
"""
|
||||
)
|
||||
expect(sheet).to_be_visible()
|
||||
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
|
||||
@@ -63,6 +63,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
|
||||
"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_sign_out_release.py "
|
||||
"tests/e2e/test_mobile_today_handoff_release.py "
|
||||
"tests/e2e/test_mobile_today_wrap_up_release.py "
|
||||
"tests/e2e/test_mobile_today_summary_release.py "
|
||||
|
||||
@@ -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-v122" in worker
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
@@ -36,8 +36,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
(changed_frontend / "widgets.js").write_text(
|
||||
(changed_frontend / "widgets.js").read_text() + "\n// changed\n"
|
||||
(changed_frontend / "session.js").write_text(
|
||||
(changed_frontend / "session.js").read_text() + "\n// changed\n"
|
||||
)
|
||||
|
||||
changed = build_frontend(changed_frontend)
|
||||
@@ -49,7 +49,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
first = build_frontend(FRONTEND)
|
||||
|
||||
assert set(first.feature_bundles) == {
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
|
||||
"today-timer", "security-center",
|
||||
}
|
||||
assert first.dashboard_html.count("<script src=") == 1
|
||||
@@ -75,6 +75,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
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"function createSignOutReview" not in first.runtime_bytes
|
||||
assert b"function createSignOutReview" in first.feature_bundles["sign-out"].runtime_bytes
|
||||
assert b"function createDetailDefer" not in first.runtime_bytes
|
||||
assert b"function createDetailDefer" in first.feature_bundles["today-timer"].runtime_bytes
|
||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||
|
||||
@@ -2,7 +2,132 @@ import sqlite3
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
|
||||
from src.idempotency import IdempotencyLedger
|
||||
from src.state_encryption import PrivateStateEncryptionError
|
||||
|
||||
|
||||
PRIVATE_KEY = b"i" * 32
|
||||
|
||||
|
||||
def test_authored_payloads_are_encrypted_at_rest_and_replay_after_restart(tmp_path):
|
||||
database = tmp_path / "idempotency.sqlite3"
|
||||
fingerprint_canary = "private-issue-body-canary"
|
||||
response_canary = "private-gitea-response-canary"
|
||||
ledger = IdempotencyLedger(
|
||||
database,
|
||||
ttl_seconds=600,
|
||||
max_entries=256,
|
||||
encryption_key=PRIVATE_KEY,
|
||||
)
|
||||
|
||||
assert ledger.reserve("create-201", ("issue-create", fingerprint_canary)).state == "reserved"
|
||||
ledger.complete("create-201", {"title": response_canary})
|
||||
|
||||
persisted = database.read_bytes()
|
||||
assert fingerprint_canary.encode() not in persisted
|
||||
assert response_canary.encode() not in persisted
|
||||
with sqlite3.connect(database) as connection:
|
||||
fingerprint, response = connection.execute(
|
||||
"SELECT fingerprint, response_json FROM idempotency_operations WHERE key = ?",
|
||||
("create-201",),
|
||||
).fetchone()
|
||||
assert fingerprint.startswith("v1:")
|
||||
assert response.startswith("v1:")
|
||||
|
||||
reopened = IdempotencyLedger(
|
||||
database,
|
||||
ttl_seconds=600,
|
||||
max_entries=256,
|
||||
encryption_key=PRIVATE_KEY,
|
||||
)
|
||||
replay = reopened.reserve("create-201", ("issue-create", fingerprint_canary))
|
||||
assert replay.state == "completed"
|
||||
assert replay.response == {"title": response_canary}
|
||||
|
||||
|
||||
def test_legacy_plaintext_completed_row_is_migrated_without_losing_replay(tmp_path):
|
||||
database = tmp_path / "legacy.sqlite3"
|
||||
ledger = IdempotencyLedger(
|
||||
database,
|
||||
ttl_seconds=600,
|
||||
max_entries=256,
|
||||
encryption_key=PRIVATE_KEY,
|
||||
clock=lambda: 100.0,
|
||||
)
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency_operations "
|
||||
"(key, fingerprint, status, response_json, created_at, completed_at) "
|
||||
"VALUES (?, ?, 'completed', ?, ?, ?)",
|
||||
(
|
||||
"legacy-comment",
|
||||
'["issue-comment","stackchain/api",7,"legacy secret"]',
|
||||
'{"id":42,"body":"legacy response"}',
|
||||
90.0,
|
||||
95.0,
|
||||
),
|
||||
)
|
||||
|
||||
replay = ledger.reserve(
|
||||
"legacy-comment",
|
||||
("issue-comment", "stackchain/api", 7, "legacy secret"),
|
||||
)
|
||||
|
||||
assert replay.state == "completed"
|
||||
assert replay.response == {"id": 42, "body": "legacy response"}
|
||||
with sqlite3.connect(database) as connection:
|
||||
fingerprint, response = connection.execute(
|
||||
"SELECT fingerprint, response_json FROM idempotency_operations WHERE key = ?",
|
||||
("legacy-comment",),
|
||||
).fetchone()
|
||||
assert fingerprint.startswith("v1:")
|
||||
assert response.startswith("v1:")
|
||||
|
||||
|
||||
def test_wrong_key_fails_closed_before_a_completed_result_can_replay(tmp_path):
|
||||
database = tmp_path / "wrong-key.sqlite3"
|
||||
ledger = IdempotencyLedger(
|
||||
database, ttl_seconds=600, max_entries=256, encryption_key=PRIVATE_KEY
|
||||
)
|
||||
fingerprint = ("issue-comment", "stackchain/api", 7, "secret")
|
||||
assert ledger.reserve("comment-7", fingerprint).state == "reserved"
|
||||
ledger.complete("comment-7", {"id": 42})
|
||||
|
||||
wrong_key = IdempotencyLedger(
|
||||
database, ttl_seconds=600, max_entries=256, encryption_key=b"x" * 32
|
||||
)
|
||||
with pytest.raises(
|
||||
PrivateStateEncryptionError, match="private state could not be decrypted"
|
||||
):
|
||||
wrong_key.reserve("comment-7", fingerprint)
|
||||
|
||||
|
||||
def test_completed_ciphertext_cannot_be_substituted_between_operation_keys(tmp_path):
|
||||
database = tmp_path / "substitution.sqlite3"
|
||||
ledger = IdempotencyLedger(
|
||||
database, ttl_seconds=600, max_entries=256, encryption_key=PRIVATE_KEY
|
||||
)
|
||||
fingerprint = ("issue-create", "stackchain/api", "same authored request")
|
||||
for key, number in (("create-a", 41), ("create-b", 42)):
|
||||
assert ledger.reserve(key, fingerprint).state == "reserved"
|
||||
ledger.complete(key, {"number": number})
|
||||
with sqlite3.connect(database) as connection:
|
||||
responses = dict(
|
||||
connection.execute(
|
||||
"SELECT key, response_json FROM idempotency_operations"
|
||||
).fetchall()
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE idempotency_operations SET response_json = ? WHERE key = ?",
|
||||
(responses["create-b"], "create-a"),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
PrivateStateEncryptionError, match="private state could not be decrypted"
|
||||
):
|
||||
ledger.reserve("create-a", fingerprint)
|
||||
|
||||
|
||||
def test_completed_result_replays_after_ledger_reconstruction(tmp_path):
|
||||
|
||||
@@ -435,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-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -81,10 +81,112 @@ const controller = status.createRefreshController({{
|
||||
}
|
||||
|
||||
|
||||
def test_live_data_status_sheet_pauses_today_contains_focus_and_closes_through_back():
|
||||
script = f"""
|
||||
const status = require({json.dumps(str(STATUS))});
|
||||
function element(name) {{
|
||||
return {{name, hidden:false, inert:false, listeners:{{}}, focused:0,
|
||||
addEventListener(type, fn) {{ this.listeners[type] = fn; }},
|
||||
focus() {{ this.focused += 1; }},
|
||||
}};
|
||||
}}
|
||||
const trigger = element('trigger');
|
||||
trigger.attrs = {{}};
|
||||
trigger.setAttribute = (name, value) => trigger.attrs[name] = value;
|
||||
const close = element('close');
|
||||
const refresh = element('refresh');
|
||||
const back = element('return');
|
||||
const sheet = element('sheet');
|
||||
sheet.hidden = true;
|
||||
sheet.querySelectorAll = () => [close, refresh, back];
|
||||
const header = element('header');
|
||||
const main = element('main');
|
||||
const listeners = {{}};
|
||||
const history = {{state:null, pushes:0, backs:0,
|
||||
pushState(state) {{ this.state=state; this.pushes += 1; }},
|
||||
back() {{ this.backs += 1; }},
|
||||
}};
|
||||
const timerView = {{begins:[], finishes:0,
|
||||
beginDetour(reason) {{ this.begins.push(reason); return {{identity:'issue:r:42', reason}}; }},
|
||||
finishDetour() {{ this.finishes += 1; return {{resumed:true}}; }},
|
||||
}};
|
||||
const paused = element('paused');
|
||||
paused.hidden = true;
|
||||
const controller = status.createSheetController({{
|
||||
sheet, trigger, closeButton:close, returnButton:back, pausedStatus:paused,
|
||||
timerView, history, historyTarget:{{addEventListener:(name, fn) => listeners[name]=fn}},
|
||||
escapeTarget:{{addEventListener:(name, fn) => listeners[name]=fn}},
|
||||
backgroundElements:[header, main],
|
||||
}});
|
||||
controller.start();
|
||||
controller.open();
|
||||
const opened = {{hidden:sheet.hidden, expanded:trigger.attrs['aria-expanded'], paused:paused.hidden, inert:[header.inert, main.inert],
|
||||
begins:timerView.begins, pushes:history.pushes, closeFocused:close.focused}};
|
||||
let prevented = 0;
|
||||
listeners.keydown({{key:'Tab', target:back, shiftKey:false, preventDefault:()=>prevented++}});
|
||||
const trapped = {{prevented, closeFocused:close.focused}};
|
||||
controller.close();
|
||||
const requested = {{backs:history.backs, finishes:timerView.finishes}};
|
||||
history.state = null;
|
||||
listeners.popstate({{state:null}});
|
||||
process.stdout.write(JSON.stringify({{opened, trapped, requested, closed:{{hidden:sheet.hidden, paused:paused.hidden,
|
||||
expanded:trigger.attrs['aria-expanded'], inert:[header.inert, main.inert], finishes:timerView.finishes,
|
||||
triggerFocused:trigger.focused}}}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
|
||||
assert result == {
|
||||
"opened": {
|
||||
"hidden": False,
|
||||
"expanded": "true",
|
||||
"paused": False,
|
||||
"inert": [True, True],
|
||||
"begins": ["live-data-status"],
|
||||
"pushes": 1,
|
||||
"closeFocused": 1,
|
||||
},
|
||||
"trapped": {"prevented": 1, "closeFocused": 2},
|
||||
"requested": {"backs": 1, "finishes": 0},
|
||||
"closed": {
|
||||
"hidden": True,
|
||||
"paused": True,
|
||||
"expanded": "false",
|
||||
"inert": [False, False],
|
||||
"finishes": 1,
|
||||
"triggerFocused": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_live_data_status_without_running_today_hides_return_and_does_not_resume():
|
||||
script = f"""
|
||||
const status = require({json.dumps(str(STATUS))});
|
||||
function element(hidden=false) {{ return {{hidden, inert:false, listeners:{{}}, attrs:{{}},
|
||||
addEventListener(name, fn) {{ this.listeners[name]=fn; }}, focus() {{}},
|
||||
setAttribute(name, value) {{ this.attrs[name]=value; }}, querySelectorAll() {{ return []; }} }}; }}
|
||||
const sheet=element(true), trigger=element(), close=element(), returnButton=element();
|
||||
let finishes=0;
|
||||
const controller=status.createSheetController({{
|
||||
sheet,trigger,closeButton:close,returnButton,
|
||||
timerView:{{beginDetour:()=>null,finishDetour:()=>finishes++}},
|
||||
}});
|
||||
controller.start();
|
||||
controller.open();
|
||||
const opened={{returnHidden:returnButton.hidden, expanded:trigger.attrs['aria-expanded']}};
|
||||
controller.close();
|
||||
process.stdout.write(JSON.stringify({{opened, finishes}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"opened": {"returnHidden": True, "expanded": "true"},
|
||||
"finishes": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_live_data_status_has_accessible_mobile_safe_sheet_contract():
|
||||
html = HTML.read_text()
|
||||
css = CSS.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
status_source = STATUS.read_text()
|
||||
|
||||
assert 'id="open-live-data-status"' in html
|
||||
assert 'aria-controls="live-data-status-sheet"' in html
|
||||
@@ -92,10 +194,15 @@ def test_live_data_status_has_accessible_mobile_safe_sheet_contract():
|
||||
assert 'aria-labelledby="live-data-status-heading"' in html
|
||||
assert 'id="live-data-status-feeds"' in html
|
||||
assert 'id="refresh-live-data"' in html
|
||||
assert 'id="return-from-live-data-status"' in html
|
||||
assert 'id="live-data-status-today-paused"' in html
|
||||
assert '<script src="static/live-data-status.js"></script>' in html
|
||||
assert ".live-data-status-panel" in css
|
||||
assert "width:min(560px,100%)" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "liveDataStatus.describe" in dashboard
|
||||
assert "liveDataStatus.createRefreshController" in dashboard
|
||||
assert "liveDataStatus.mount" in dashboard
|
||||
assert "createSheetController" in status_source
|
||||
assert "backgroundElements:[qs('header'), qs('main'), qs('#mobile-task-dock')]" in status_source
|
||||
assert "contextPoller.getState()" in dashboard
|
||||
@@ -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-v122" in worker
|
||||
assert "stackchain-dashboard-shell-v123" 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-v122" in worker
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -10,7 +10,7 @@ def run_scenario(script: str) -> dict:
|
||||
harness = r"""
|
||||
const createMobileDeviceSetup = require(__MODULE__);
|
||||
class FakeTarget {
|
||||
constructor() { this.listeners = {}; this.hidden = false; this.disabled = false; this.textContent = ''; }
|
||||
constructor() { this.listeners = {}; this.hidden = false; this.disabled = false; this.textContent = ''; this.inert = false; }
|
||||
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
|
||||
async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); }
|
||||
focus() { state.focused = this; }
|
||||
@@ -39,6 +39,15 @@ const promptSummary = new FakeTarget();
|
||||
const promptLauncher = new FakeTarget();
|
||||
const promptDismiss = new FakeTarget();
|
||||
const returnButton = new FakeTarget();
|
||||
const backgroundHeader = new FakeTarget();
|
||||
const backgroundMain = new FakeTarget(); backgroundMain.inert = true;
|
||||
sheet.querySelectorAll = () => [closeButton, installButton, offlineButton, returnButton];
|
||||
const historyTarget = new FakeTarget();
|
||||
const history = {
|
||||
state:{section:'home'},
|
||||
pushState(state) { this.state = state; },
|
||||
back() { this.state = {section:'home'}; historyTarget.dispatch('popstate', {state:this.state}); },
|
||||
};
|
||||
let now = 1000;
|
||||
const promptStorage = {
|
||||
values:{},
|
||||
@@ -57,6 +66,8 @@ const setup = createMobileDeviceSetup({
|
||||
installStatus, offlineStatus, protectionStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget,
|
||||
promptCard, promptSummary, promptLauncher, promptDismiss,
|
||||
returnButton, isMobile:() => true,
|
||||
backgroundElements:[backgroundHeader, backgroundMain],
|
||||
history, historyTarget,
|
||||
timerView:{
|
||||
beginDetour:reason => { state.events.push('pause'); state.beginCalls.push(reason); return {reason}; },
|
||||
finishDetour:() => { state.events.push('resume'); state.finishCalls += 1; },
|
||||
@@ -206,6 +217,60 @@ process.stdout.write(JSON.stringify({
|
||||
assert result == {"hidden": True, "launcherFocused": True}
|
||||
|
||||
|
||||
def test_open_setup_contains_focus_and_restores_background_inert_state():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
state.focused = returnButton;
|
||||
let preventedForward = false;
|
||||
await escapeTarget.dispatch('keydown', {key:'Tab', target:returnButton, preventDefault(){preventedForward=true;}});
|
||||
const forwardFocus = state.focused === closeButton;
|
||||
state.focused = closeButton;
|
||||
let preventedReverse = false;
|
||||
await escapeTarget.dispatch('keydown', {key:'Tab', shiftKey:true, target:closeButton, preventDefault(){preventedReverse=true;}});
|
||||
const reverseFocus = state.focused === returnButton;
|
||||
const inertWhileOpen = [backgroundHeader.inert, backgroundMain.inert];
|
||||
await closeButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
preventedForward, forwardFocus, preventedReverse, reverseFocus,
|
||||
inertWhileOpen,
|
||||
inertAfterClose:[backgroundHeader.inert, backgroundMain.inert],
|
||||
launcherFocused:state.focused === launcher,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"preventedForward": True,
|
||||
"forwardFocus": True,
|
||||
"preventedReverse": True,
|
||||
"reverseFocus": True,
|
||||
"inertWhileOpen": [True, True],
|
||||
"inertAfterClose": [False, True],
|
||||
"launcherFocused": True,
|
||||
}
|
||||
|
||||
|
||||
def test_browser_back_closes_setup_and_restores_launcher_focus():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
const marked = history.state.deviceSetup === true;
|
||||
history.back();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
process.stdout.write(JSON.stringify({
|
||||
marked,
|
||||
hidden:sheet.hidden,
|
||||
backgroundReleased:backgroundHeader.inert === false,
|
||||
launcherFocused:state.focused === launcher,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"marked": True,
|
||||
"hidden": True,
|
||||
"backgroundReleased": True,
|
||||
"launcherFocused": True,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_setup_pauses_before_readiness_and_each_exit_resumes_once():
|
||||
result = run_scenario("""
|
||||
state.events = [];
|
||||
@@ -258,6 +323,26 @@ process.stdout.write(JSON.stringify({
|
||||
}
|
||||
|
||||
|
||||
def test_blocked_notification_step_becomes_a_check_again_action():
|
||||
result = run_scenario("""
|
||||
readiness.push = {
|
||||
state:'blocked',
|
||||
detail:'Notifications are blocked. Allow them in browser settings, then check again.',
|
||||
actionLabel:'Check again',
|
||||
};
|
||||
await launcher.dispatch('click', {currentTarget:launcher});
|
||||
await pushButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({label:pushButton.textContent,detail:pushStatus.textContent,calls:state.pushCalls,hidden:pushButton.hidden}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"label": "Check again",
|
||||
"detail": "Notifications are blocked. Allow them in browser settings, then check again.",
|
||||
"calls": 1,
|
||||
"hidden": False,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
root = MODULE.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
@@ -293,9 +378,12 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "protectStorage:() => deviceStorage.requestPersistence()" in dashboard
|
||||
assert "clearPrivateDeviceData:root.stackchainPrivateDeviceData" in storage_module
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "notificationReadiness:() => pushController?.notificationReadiness()" in dashboard
|
||||
assert "controller.recoverPermission('updates')" in dashboard
|
||||
assert "controller.recoverPermission('deadline')" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v122" in worker
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
CONTROLLER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-first-task.js"
|
||||
|
||||
|
||||
def run_node(body: str) -> dict:
|
||||
script = f"""
|
||||
const createFirstTask = require({json.dumps(str(CONTROLLER))});
|
||||
class Element {{
|
||||
constructor() {{ this.listeners = {{}}; this.disabled = false; this.hidden = false; this.open = false; this.textContent = ''; this.focused = false; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||
click() {{ this.listeners.click?.({{preventDefault() {{}}}}); }}
|
||||
showModal() {{ this.open = true; }}
|
||||
close() {{ this.open = false; this.listeners.close?.(); }}
|
||||
focus() {{ this.focused = true; }}
|
||||
}}
|
||||
{body}
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_first_task_activation_is_account_bound_and_completes_only_when_today_is_active():
|
||||
result = run_node(
|
||||
"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key => values.get(key) || null, setItem:(key,value) => values.set(key,value)};
|
||||
const sheet = new Element();
|
||||
let login = 'Timmy';
|
||||
let hasWork = false;
|
||||
let todayActive = false;
|
||||
const controller = createFirstTask({
|
||||
storage, getLogin:() => login, hasWork:() => hasWork, isTodayActive:() => todayActive,
|
||||
isOnline:() => true, mediaQuery:{matches:true}, sheet, title:new Element(),
|
||||
findButton:new Element(), createButton:new Element(), setupButton:new Element(),
|
||||
closeButton:new Element(), status:new Element(), onFind() {}, onCreate() {}, onSetup() {},
|
||||
});
|
||||
const before = [controller.required(), controller.open(), sheet.open];
|
||||
hasWork = true;
|
||||
const awaitingStart = controller.refresh();
|
||||
const stillRequired = controller.required();
|
||||
todayActive = true;
|
||||
const completed = controller.refresh();
|
||||
const timmyRequired = controller.required();
|
||||
login = 'alexander'; hasWork = false; todayActive = false;
|
||||
const alexanderRequired = controller.required();
|
||||
process.stdout.write(JSON.stringify({before, awaitingStart, stillRequired, completed, sheetOpen:sheet.open, timmyRequired, alexanderRequired, values:[...values]}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"before": [True, True, True],
|
||||
"awaitingStart": "awaiting-start",
|
||||
"stillRequired": True,
|
||||
"completed": "completed",
|
||||
"sheetOpen": False,
|
||||
"timmyRequired": False,
|
||||
"alexanderRequired": True,
|
||||
"values": [["stackchain.first-task.v1:timmy", "complete"]],
|
||||
}
|
||||
|
||||
|
||||
def test_first_task_activation_resumes_with_finish_starting_guidance():
|
||||
result = run_node(
|
||||
"""
|
||||
const sheet = new Element();
|
||||
const title = new Element();
|
||||
const findButton = new Element();
|
||||
const createButton = new Element();
|
||||
const status = new Element();
|
||||
const controller = createFirstTask({
|
||||
storage:{getItem:() => null, setItem() {}}, getLogin:() => 'timmy', hasWork:() => true,
|
||||
isTodayActive:() => false, isOnline:() => true, mediaQuery:{matches:true}, sheet, title,
|
||||
findButton, createButton, setupButton:new Element(), closeButton:new Element(), status,
|
||||
onFind() {}, onCreate() {}, onSetup() {},
|
||||
});
|
||||
controller.open();
|
||||
process.stdout.write(JSON.stringify({
|
||||
required:controller.required(), title:title.textContent, status:status.textContent,
|
||||
find:findButton.textContent, create:createButton.textContent,
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"required": True,
|
||||
"title": "Finish starting your first task",
|
||||
"status": "Your task is ready. Start it from Find or create and start another task.",
|
||||
"find": "Find & start",
|
||||
"create": "Create & start",
|
||||
}
|
||||
|
||||
|
||||
def test_first_task_activation_routes_existing_flows_and_keeps_create_available_offline():
|
||||
result = run_node(
|
||||
"""
|
||||
const calls = [];
|
||||
const sheet = new Element();
|
||||
const findButton = new Element();
|
||||
const createButton = new Element();
|
||||
const setupButton = new Element();
|
||||
const closeButton = new Element();
|
||||
const status = new Element();
|
||||
let online = false;
|
||||
const controller = createFirstTask({
|
||||
storage:{getItem:() => null, setItem() {}}, getLogin:() => 'timmy', hasWork:() => false,
|
||||
isOnline:() => online, mediaQuery:{matches:true}, sheet, findButton, createButton,
|
||||
setupButton, closeButton, status,
|
||||
onFind:() => calls.push('find'), onCreate:() => calls.push('create'), onSetup:() => calls.push('setup'),
|
||||
});
|
||||
controller.start();
|
||||
controller.open();
|
||||
const offline = {findDisabled:findButton.disabled, createDisabled:createButton.disabled, status:status.textContent, createFocused:createButton.focused};
|
||||
findButton.click();
|
||||
createButton.click();
|
||||
controller.open();
|
||||
setupButton.click();
|
||||
controller.open();
|
||||
online = true;
|
||||
controller.render();
|
||||
findButton.click();
|
||||
process.stdout.write(JSON.stringify({offline, calls, sheetOpen:sheet.open}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"offline": {
|
||||
"findDisabled": True,
|
||||
"createDisabled": False,
|
||||
"status": "You are offline. Create a task now and it will stay in Drafts until you reconnect.",
|
||||
"createFocused": True,
|
||||
},
|
||||
"calls": ["create", "setup", "find"],
|
||||
"sheetOpen": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_phone_safe_first_task_activation():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<dialog class="mobile-first-task" id="mobile-first-task" aria-labelledby="mobile-first-task-title">' in html
|
||||
assert '<h2 id="mobile-first-task-title">Start your first task</h2>' in html
|
||||
assert 'id="mobile-first-task-find" type="button">Find & start</button>' in html
|
||||
assert 'id="mobile-first-task-create" type="button">Create & start</button>' in html
|
||||
assert 'id="mobile-first-task-setup" type="button">Make this phone work-ready</button>' in html
|
||||
assert '<script src="static/mobile-first-task.js"></script>' in html
|
||||
assert "const mobileFirstTask = createMobileFirstTask({" in html
|
||||
assert "isTodayActive: () => workSession.checkpointed()" in html
|
||||
assert "shouldActivate: () => mobileFirstTask.required()" in html
|
||||
assert "openActivation: () => mobileFirstTask.open()" in html
|
||||
assert "mobileFirstTask.refresh()" in html
|
||||
assert '.mobile-first-task { box-sizing:border-box; width:100%;' in html
|
||||
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
||||
assert '.mobile-first-task-actions button { min-height:48px;' in html
|
||||
@@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v122" in worker
|
||||
assert "stackchain-dashboard-shell-v123" in worker
|
||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||
@@ -358,7 +358,7 @@ 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-v122" in service_worker
|
||||
assert "stackchain-dashboard-shell-v123" in service_worker
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -140,6 +140,35 @@ process.stdout.write(JSON.stringify({{mode:entry.open(), calls}}));
|
||||
assert json.loads(result.stdout) == {"mode": "plan", "calls": ["plan"]}
|
||||
|
||||
|
||||
def test_mobile_work_entry_guides_an_empty_first_workspace_before_find():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
const calls = [];
|
||||
let activationRequired = true;
|
||||
const entry = createEntry({{
|
||||
isTodayActive: () => false,
|
||||
isTodayResumable: () => false,
|
||||
getTodayCount: () => 0,
|
||||
getEligibleCount: () => 0,
|
||||
shouldActivate: () => activationRequired,
|
||||
queueLauncher: {{recommend: () => ({{name:'find'}}), open: () => {{}}}},
|
||||
openActivation: () => calls.push('activate'),
|
||||
findWork: () => calls.push('find'),
|
||||
}});
|
||||
const modes = [entry.open()];
|
||||
activationRequired = false;
|
||||
modes.push(entry.open());
|
||||
process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"modes": ["activate", "find"],
|
||||
"calls": ["activate", "find"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
|
||||
@@ -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-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" 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
|
||||
+137
-3
@@ -22,6 +22,7 @@ const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callba
|
||||
const deadlineDays = {value:'2', disabled:false, addEventListener:(_name, callback) => state.deadlineDaysChange = callback};
|
||||
const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }};
|
||||
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
|
||||
const testControl = {hidden:true, disabled:false, addEventListener:(_name, callback) => state.testDelivery = callback};
|
||||
const deadlineSnooze = {hidden:true};
|
||||
const deadlineSnoozeStatus = {set textContent(value) { state.snoozeText = value; }, get textContent() { return state.snoozeText; }};
|
||||
const deadlineSnoozeReview = {disabled:false, addEventListener:(_name, callback) => state.reviewSnooze = callback};
|
||||
@@ -31,10 +32,10 @@ const registration = {pushManager:{
|
||||
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
|
||||
}};
|
||||
const feature = createPushNotifications({
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
|
||||
onReviewDeadlines:() => { state.reviewed = true; },
|
||||
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
|
||||
notification: state.notification = {permission:'default', requestPermission:async () => { state.prompts += 1; state.notification.permission = state.permission || 'granted'; return state.notification.permission; }},
|
||||
serviceWorker: {ready:Promise.resolve(registration)},
|
||||
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); if (state.failResume && options.method === 'DELETE' && url.endsWith('/deadlines/snooze')) throw new Error('offline'); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
|
||||
});
|
||||
@@ -69,6 +70,27 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["text"] == "New update notifications enabled for this device."
|
||||
|
||||
|
||||
def test_degraded_device_can_run_a_test_notification_and_show_recovery():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,public_key:'AQID',delivery_health:{unread:{state:'degraded',consecutive_failures:3,reason:'timeout'}}};
|
||||
state.current = existing;
|
||||
await feature.init();
|
||||
const degraded = {text:state.text,hidden:testControl.hidden};
|
||||
state.server = {delivery_state:'healthy',delivered:true};
|
||||
await state.testDelivery();
|
||||
process.stdout.write(JSON.stringify({degraded,text:state.text,hidden:testControl.hidden,disabled:testControl.disabled,requests:state.requests}));
|
||||
""")
|
||||
|
||||
assert result["degraded"] == {
|
||||
"text": "Update notifications need attention after 3 failed deliveries. Send a test notification.",
|
||||
"hidden": False,
|
||||
}
|
||||
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/test", "POST"]
|
||||
assert result["text"] == "Test delivered. Update notifications are working on this device."
|
||||
assert result["hidden"] is False
|
||||
assert result["disabled"] is False
|
||||
|
||||
|
||||
def test_deadline_opt_in_reuses_subscription_and_sends_local_timezone_without_second_prompt():
|
||||
result = run_scenario("""
|
||||
state.current = existing;
|
||||
@@ -158,11 +180,121 @@ await feature.enableDeadline();
|
||||
process.stdout.write(JSON.stringify({checked:deadlineControl.checked, readiness:feature.deadlineReadiness(), requests:state.requests}));
|
||||
""")
|
||||
assert denied["checked"] is False
|
||||
assert denied["readiness"]["state"] == "incomplete"
|
||||
assert denied["readiness"]["state"] == "blocked"
|
||||
assert denied["readiness"]["actionLabel"] == "Check again"
|
||||
assert "blocked" in denied["readiness"]["detail"].lower()
|
||||
assert [request[0] for request in denied["requests"]] == ["api/v1/push-subscription"]
|
||||
|
||||
|
||||
def test_blocked_update_setup_can_recheck_without_reprompting_or_mutating():
|
||||
result = run_scenario("""
|
||||
state.permission = 'denied';
|
||||
await feature.init();
|
||||
control.checked = true;
|
||||
await state.change();
|
||||
const blocked = feature.notificationReadiness();
|
||||
await feature.recoverPermission();
|
||||
process.stdout.write(JSON.stringify({blocked,after:feature.notificationReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests}));
|
||||
""")
|
||||
|
||||
assert result["blocked"] == {
|
||||
"state": "blocked",
|
||||
"detail": "Notifications are blocked. Allow them in browser settings, then check again.",
|
||||
"actionLabel": "Check again",
|
||||
}
|
||||
assert result["after"] == result["blocked"]
|
||||
assert result["prompts"] == 1
|
||||
assert result["subscriptions"] == 0
|
||||
assert result["requests"] == [["api/v1/push-subscription", "GET", ""]]
|
||||
|
||||
|
||||
def test_previously_blocked_permission_starts_a_resumable_update_intent():
|
||||
result = run_scenario("""
|
||||
state.permission = 'denied';
|
||||
state.notification.permission = 'denied';
|
||||
await feature.init();
|
||||
await feature.recoverPermission('updates');
|
||||
state.notification.permission = 'granted';
|
||||
const readyToRecover = feature.notificationReadiness();
|
||||
await feature.recoverPermission('updates');
|
||||
process.stdout.write(JSON.stringify({readyToRecover,readiness:feature.notificationReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests}));
|
||||
""")
|
||||
|
||||
assert result["readyToRecover"]["actionLabel"] == "Check again"
|
||||
assert result["readiness"]["state"] == "complete"
|
||||
assert result["prompts"] == 0
|
||||
assert result["subscriptions"] == 1
|
||||
assert [request[0:2] for request in result["requests"]] == [
|
||||
["api/v1/push-subscription", "GET"],
|
||||
["api/v1/push-subscription", "PUT"],
|
||||
]
|
||||
|
||||
|
||||
def test_permission_recovery_resumes_pending_update_subscription_once():
|
||||
result = run_scenario("""
|
||||
state.permission = 'denied';
|
||||
await feature.init();
|
||||
control.checked = true;
|
||||
await state.change();
|
||||
state.notification.permission = 'granted';
|
||||
const readyToRecover = feature.notificationReadiness();
|
||||
await feature.recoverPermission();
|
||||
await feature.recoverPermission();
|
||||
process.stdout.write(JSON.stringify({readyToRecover,readiness:feature.notificationReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests}));
|
||||
""")
|
||||
|
||||
assert result["readyToRecover"] == {
|
||||
"state": "blocked",
|
||||
"detail": "Notification permission changed. Check again to finish setup.",
|
||||
"actionLabel": "Check again",
|
||||
}
|
||||
assert result["readiness"]["state"] == "complete"
|
||||
assert result["prompts"] == 1
|
||||
assert result["subscriptions"] == 1
|
||||
assert [request[0:2] for request in result["requests"]] == [
|
||||
["api/v1/push-subscription", "GET"],
|
||||
["api/v1/push-subscription", "PUT"],
|
||||
]
|
||||
|
||||
|
||||
def test_permission_recovery_resumes_pending_deadline_choices_once():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,reminder_days:2,public_key:'AQID'};
|
||||
state.permission = 'denied';
|
||||
await feature.init();
|
||||
deadlineHour.value = '17';
|
||||
deadlineDays.value = '7';
|
||||
await feature.enableDeadline();
|
||||
const blocked = feature.deadlineReadiness();
|
||||
state.notification.permission = 'granted';
|
||||
const readyToRecover = feature.deadlineReadiness();
|
||||
await feature.recoverPermission();
|
||||
await feature.recoverPermission();
|
||||
process.stdout.write(JSON.stringify({blocked,readyToRecover,readiness:feature.deadlineReadiness(),prompts:state.prompts,subscriptions:state.subscriptions.length,requests:state.requests}));
|
||||
""")
|
||||
|
||||
assert result["blocked"]["state"] == "blocked"
|
||||
assert result["blocked"]["actionLabel"] == "Check again"
|
||||
assert result["readyToRecover"] == {
|
||||
"state": "blocked",
|
||||
"detail": "Notification permission changed. Check again to finish setup.",
|
||||
"actionLabel": "Check again",
|
||||
}
|
||||
assert result["readiness"] == {
|
||||
"state": "complete",
|
||||
"detail": "Deadline reminders enabled for 17:00 local time, next 7 days.",
|
||||
}
|
||||
assert result["prompts"] == 1
|
||||
assert result["subscriptions"] == 1
|
||||
assert [request[0:2] for request in result["requests"]] == [
|
||||
["api/v1/push-subscription", "GET"],
|
||||
["api/v1/push-subscription", "PUT"],
|
||||
["api/v1/push-subscription/deadlines", "PUT"],
|
||||
]
|
||||
assert json.loads(result["requests"][-1][2])["reminder_hour"] == 17
|
||||
assert json.loads(result["requests"][-1][2])["reminder_days"] == 7
|
||||
|
||||
|
||||
def test_active_deadline_snooze_is_visible_in_agenda_with_a_local_resume_time():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:9,reminder_days:2,snoozed_until:1765003600,public_key:'AQID'};
|
||||
@@ -220,6 +352,7 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||
|
||||
assert 'id="push-updates"' in html
|
||||
assert 'id="push-update-status"' in html
|
||||
assert 'id="push-test"' in html
|
||||
assert 'id="push-deadlines"' in html
|
||||
assert 'id="push-deadline-hour"' in html
|
||||
assert 'id="push-deadline-days"' in html
|
||||
@@ -227,6 +360,7 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||
assert 'id="push-deadline-status"' in html
|
||||
assert '<script src="static/push-notifications.js"></script>' in html
|
||||
assert "createPushNotifications({" in dashboard
|
||||
assert "testControl:qs('#push-test')" in dashboard
|
||||
assert "BASE + 'static/push-notifications.js'" in worker
|
||||
assert ".push-update-control" in css and "min-height:44px" in css
|
||||
assert "pywebpush==" in requirements
|
||||
|
||||
@@ -200,6 +200,46 @@ def test_subscription_store_delivers_each_unread_thread_once_per_device(tmp_path
|
||||
assert second == []
|
||||
|
||||
|
||||
def test_delivery_health_is_bounded_per_channel_and_recovers_after_success(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
|
||||
store.mark_delivery_failed("session-a", "unread", "timeout", now=100)
|
||||
store.mark_delivery_failed("session-a", "unread", "provider", now=110)
|
||||
store.mark_delivery_failed("session-a", "deadline", "provider", now=120)
|
||||
|
||||
assert store.delivery_health("session-a") == {
|
||||
"unread": {
|
||||
"state": "degraded",
|
||||
"consecutive_failures": 2,
|
||||
"last_attempted_at": 110,
|
||||
"last_succeeded_at": None,
|
||||
"reason": "provider",
|
||||
},
|
||||
"deadline": {
|
||||
"state": "degraded",
|
||||
"consecutive_failures": 1,
|
||||
"last_attempted_at": 120,
|
||||
"last_succeeded_at": None,
|
||||
"reason": "provider",
|
||||
},
|
||||
}
|
||||
|
||||
store.mark_delivery_succeeded("session-a", "unread", now=130)
|
||||
|
||||
assert store.delivery_health("session-a")["unread"] == {
|
||||
"state": "healthy",
|
||||
"consecutive_failures": 0,
|
||||
"last_attempted_at": 130,
|
||||
"last_succeeded_at": 130,
|
||||
"reason": None,
|
||||
}
|
||||
assert store.delivery_health("session-a")["deadline"]["state"] == "degraded"
|
||||
|
||||
|
||||
def test_subscription_store_reopens_a_delivered_thread_when_its_revision_changes(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
@@ -918,6 +958,42 @@ async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_pat
|
||||
assert store.claim_unseen({8})[0].session_id == "session-a"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unread_dispatch_records_degraded_health_then_recovers(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
revision = 0
|
||||
|
||||
async def unread():
|
||||
nonlocal revision
|
||||
revision += 1
|
||||
return {"items": [{"id": 42, "updated_at": f"r{revision}"}], "complete": True}
|
||||
|
||||
async def timeout_send(_subscription, _payload):
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
config = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
for _ in range(3):
|
||||
assert await dispatch_unread_updates(store, config, unread, timeout_send) == 0
|
||||
|
||||
health = store.delivery_health("session-a")["unread"]
|
||||
assert health["state"] == "degraded"
|
||||
assert health["consecutive_failures"] == 3
|
||||
assert health["reason"] == "timeout"
|
||||
|
||||
async def succeed(_subscription, _payload):
|
||||
return None
|
||||
|
||||
assert await dispatch_unread_updates(store, config, unread, succeed) == 1
|
||||
recovered = store.delivery_health("session-a")["unread"]
|
||||
assert recovered["state"] == "healthy"
|
||||
assert recovered["consecutive_failures"] == 0
|
||||
assert recovered["last_succeeded_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unexpected_device_failure_waits_for_siblings_and_retries_only_that_device(tmp_path):
|
||||
path = tmp_path / "push.sqlite3"
|
||||
@@ -967,6 +1043,7 @@ async def test_unexpected_device_failure_waits_for_siblings_and_retries_only_tha
|
||||
assert [(item.session_id, item.thread_ids) for item in remaining] == [
|
||||
("failing-device", (8,))
|
||||
]
|
||||
assert store.delivery_health("failing-device")["unread"]["reason"] == "timeout"
|
||||
|
||||
retried = []
|
||||
|
||||
@@ -1184,6 +1261,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||
"reminder_hour": 9,
|
||||
"reminder_days": 2,
|
||||
"snoozed_until": None,
|
||||
"delivery_health": {},
|
||||
}
|
||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||
assert (await main.push_status(request))["subscribed"] is True
|
||||
@@ -1191,6 +1269,79 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||
assert (await main.push_status(request))["subscribed"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_can_send_a_real_test_notification(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
subscription = {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
}
|
||||
store.upsert("session-a", subscription)
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
monkeypatch.setattr(main, "_push_configuration", lambda: configuration)
|
||||
|
||||
async def management_id(_session):
|
||||
return "session-a"
|
||||
|
||||
sent = []
|
||||
|
||||
async def send(delivery, payload, config):
|
||||
sent.append((delivery, json.loads(payload), config))
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
monkeypatch.setattr(main, "send_web_push", send, raising=False)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
|
||||
assert await main.test_push_notification(request) == {
|
||||
"delivery_state": "healthy",
|
||||
"delivered": True,
|
||||
}
|
||||
assert sent == [(
|
||||
subscription,
|
||||
{
|
||||
"title": "Stackchain notifications are working",
|
||||
"body": "This device can receive private work alerts.",
|
||||
"route": "#/device-setup",
|
||||
"tag": "stackchain-push-test",
|
||||
},
|
||||
configuration,
|
||||
)]
|
||||
assert store.delivery_health("session-a")["unread"]["state"] == "healthy"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_test_notification_reports_provider_failure_as_degraded(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/device-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
monkeypatch.setattr(
|
||||
main, "_push_configuration",
|
||||
lambda: PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
)
|
||||
|
||||
async def management_id(_session):
|
||||
return "session-a"
|
||||
|
||||
async def fail(*_args):
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
monkeypatch.setattr(main, "send_web_push", fail)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
|
||||
with pytest.raises(main.HTTPException) as raised:
|
||||
await main.test_push_notification(request)
|
||||
|
||||
assert raised.value.status_code == 503
|
||||
assert raised.value.detail == "Test notification delivery failed"
|
||||
assert store.is_subscribed("session-a") is True
|
||||
assert store.delivery_health("session-a")["unread"]["reason"] == "timeout"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.saved_search_store import SavedSearchConflict, SavedSearchStore
|
||||
from src.state_encryption import PrivateStateEncryptionError
|
||||
|
||||
|
||||
def view(view_id="release", name="Release queue", query="mobile", **scope):
|
||||
@@ -16,6 +20,105 @@ def view(view_id="release", name="Release queue", query="mobile", **scope):
|
||||
}
|
||||
|
||||
|
||||
def test_saved_searches_encrypt_private_views_and_survive_restart(tmp_path):
|
||||
database = tmp_path / "saved-searches.sqlite3"
|
||||
key = b"s" * 32
|
||||
canary = "private-launch-query-canary"
|
||||
store = SavedSearchStore(database, encryption_key=key)
|
||||
|
||||
created = store.replace(
|
||||
"Timmy",
|
||||
0,
|
||||
[view(name="Private launch", query=canary, repository="private/launch")],
|
||||
)
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT views FROM saved_searches WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
assert payload.startswith("v1:")
|
||||
assert "Private launch" not in payload
|
||||
assert canary not in payload
|
||||
assert "private/launch" not in payload
|
||||
assert SavedSearchStore(database, encryption_key=key).get("timmy") == created
|
||||
|
||||
|
||||
def test_saved_searches_lazily_migrate_plaintext_without_changing_revision(tmp_path):
|
||||
database = tmp_path / "saved-searches.sqlite3"
|
||||
key = b"s" * 32
|
||||
legacy_views = [view(name="Legacy private view", query="legacy-canary")]
|
||||
SavedSearchStore(database, encryption_key=key)
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO saved_searches(login, revision, views) VALUES (?, ?, ?)",
|
||||
("timmy", 7, json.dumps(legacy_views)),
|
||||
)
|
||||
|
||||
snapshot = SavedSearchStore(database, encryption_key=key).get("Timmy")
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
migrated = connection.execute(
|
||||
"SELECT revision, views FROM saved_searches WHERE login = 'timmy'"
|
||||
).fetchone()
|
||||
assert snapshot == {"revision": 7, "views": legacy_views}
|
||||
assert migrated[0] == 7
|
||||
assert migrated[1].startswith("v1:")
|
||||
assert "legacy-canary" not in migrated[1]
|
||||
|
||||
|
||||
def test_saved_searches_fail_closed_with_wrong_key(tmp_path):
|
||||
database = tmp_path / "saved-searches.sqlite3"
|
||||
SavedSearchStore(database, encryption_key=b"s" * 32).replace(
|
||||
"timmy", 0, [view(query="wrong-key-canary")]
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
PrivateStateEncryptionError, match="private state could not be decrypted"
|
||||
):
|
||||
SavedSearchStore(database, encryption_key=b"x" * 32).get("timmy")
|
||||
|
||||
|
||||
def test_saved_searches_reject_tampered_or_malformed_payloads(tmp_path):
|
||||
database = tmp_path / "saved-searches.sqlite3"
|
||||
store = SavedSearchStore(database, encryption_key=b"s" * 32)
|
||||
store.replace("timmy", 0, [view(query="tamper-canary")])
|
||||
with sqlite3.connect(database) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT views FROM saved_searches WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
|
||||
for invalid in (payload[:-1] + ("A" if payload[-1] != "A" else "B"), "not-json"):
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"UPDATE saved_searches SET views = ? WHERE login = 'timmy'",
|
||||
(invalid,),
|
||||
)
|
||||
with pytest.raises(
|
||||
PrivateStateEncryptionError, match="private state could not be decrypted"
|
||||
):
|
||||
store.get("timmy")
|
||||
|
||||
|
||||
def test_saved_searches_reject_ciphertext_substituted_between_accounts(tmp_path):
|
||||
database = tmp_path / "saved-searches.sqlite3"
|
||||
store = SavedSearchStore(database, encryption_key=b"s" * 32)
|
||||
store.replace("timmy", 0, [view(query="timmy-private")])
|
||||
store.replace("alexander", 0, [view(query="alexander-private")])
|
||||
with sqlite3.connect(database) as connection:
|
||||
timmy_payload = connection.execute(
|
||||
"SELECT views FROM saved_searches WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
connection.execute(
|
||||
"UPDATE saved_searches SET views = ? WHERE login = 'alexander'",
|
||||
(timmy_payload,),
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
PrivateStateEncryptionError, match="private state could not be decrypted"
|
||||
):
|
||||
store.get("alexander")
|
||||
|
||||
|
||||
def test_saved_searches_are_revisioned_ordered_and_account_scoped(tmp_path):
|
||||
store = SavedSearchStore(tmp_path / "saved-searches.sqlite3")
|
||||
|
||||
|
||||
@@ -304,3 +304,139 @@ const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecu
|
||||
)
|
||||
|
||||
assert json.loads(completed.stdout) == {"backs": 1}
|
||||
|
||||
|
||||
def test_security_center_contains_focus_and_restores_prior_background_state_on_final_back():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const listeners={{}};
|
||||
const element=(id='', inert=false)=>({{
|
||||
id, inert, hidden:false, disabled:false, textContent:'', children:[], attributes:{{}},
|
||||
addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}},
|
||||
focus(){{focused=this;}}, replaceChildren(){{this.children=[];}},
|
||||
append(...values){{this.children.push(...values);}},
|
||||
setAttribute(name,value){{this.attributes[name]=value;}}, removeAttribute(name){{delete this.attributes[name];}},
|
||||
scrollIntoView(){{}},
|
||||
}});
|
||||
let focused=null;
|
||||
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)]));
|
||||
ids['active-devices-sheet'].hidden=true;
|
||||
const header=element('header');
|
||||
const main=element('main', true);
|
||||
const dock=element('mobile-task-dock');
|
||||
const dynamicAction=element('revoke-lost-phone');
|
||||
const hiddenAction=element('hidden-action'); hiddenAction.hidden=true;
|
||||
const disabledAction=element('disabled-action'); disabledAction.disabled=true;
|
||||
ids['active-devices-sheet'].querySelectorAll=()=>[
|
||||
ids['close-active-devices'], dynamicAction, hiddenAction, disabledAction,
|
||||
];
|
||||
const documentListeners={{}};
|
||||
const root={{
|
||||
document:{{
|
||||
getElementById:id=>ids[id]||null, createElement:()=>element(),
|
||||
querySelector:selector=>({{'header':header,'main':main,'#mobile-task-dock':dock}})[selector]||null,
|
||||
addEventListener:(type,listener)=>documentListeners[type]=listener,
|
||||
}},
|
||||
history:{{state:null,pushState(state){{this.state=state;}}}},
|
||||
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();
|
||||
const opened={{inert:[header.inert,main.inert,dock.inert],focused:focused?.id}};
|
||||
let shiftPrevented=false;
|
||||
documentListeners.keydown({{key:'Tab',shiftKey:true,target:ids['close-active-devices'],preventDefault(){{shiftPrevented=true;}}}});
|
||||
const afterShift=focused?.id;
|
||||
let tabPrevented=false;
|
||||
documentListeners.keydown({{key:'Tab',shiftKey:false,target:dynamicAction,preventDefault(){{tabPrevented=true;}}}});
|
||||
const afterTab=focused?.id;
|
||||
controller.navigate('devices');
|
||||
header.inert=false; // An earlier popstate listener released its own overlay background.
|
||||
listeners.popstate({{state:{{stackchainSecuritySection:'activity'}}}});
|
||||
header.inert=false; // That listener also reacts to the resulting hashchange.
|
||||
root.history.state={{stackchainSecuritySection:'activity'}};
|
||||
listeners.hashchange({{}});
|
||||
const sectionBack={{hidden:ids['active-devices-sheet'].hidden,inert:[header.inert,main.inert,dock.inert]}};
|
||||
root.history.state=null;
|
||||
listeners.popstate({{state:null}});
|
||||
console.log(JSON.stringify({{
|
||||
opened,shiftPrevented,afterShift,tabPrevented,afterTab,sectionBack,
|
||||
closed:{{hidden:ids['active-devices-sheet'].hidden,inert:[header.inert,main.inert,dock.inert],focused:focused?.id}},
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"opened": {"inert": [True, True, True], "focused": "close-active-devices"},
|
||||
"shiftPrevented": True,
|
||||
"afterShift": "revoke-lost-phone",
|
||||
"tabPrevented": True,
|
||||
"afterTab": "close-active-devices",
|
||||
"sectionBack": {"hidden": False, "inert": [True, True, True]},
|
||||
"closed": {
|
||||
"hidden": True,
|
||||
"inert": [False, True, False],
|
||||
"focused": "active-devices",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_escape_closes_security_center_through_history_and_releases_background():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const windowListeners={{}};
|
||||
const documentListeners={{}};
|
||||
const element=(id='')=>({{
|
||||
id,inert:false,hidden:false,disabled:false,textContent:'',children:[],attributes:{{}},
|
||||
addEventListener(){{}},focus(){{focused=this.id;}},replaceChildren(){{this.children=[];}},
|
||||
append(...values){{this.children.push(...values);}},setAttribute(){{}},removeAttribute(){{}},scrollIntoView(){{}},
|
||||
}});
|
||||
let focused='';
|
||||
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(id)]));
|
||||
ids['active-devices-sheet'].hidden=true;
|
||||
ids['active-devices-sheet'].querySelectorAll=()=>[ids['close-active-devices']];
|
||||
const header=element('header');
|
||||
let backs=0;
|
||||
const root={{
|
||||
document:{{
|
||||
getElementById:id=>ids[id]||null,createElement:()=>element(),
|
||||
querySelector:selector=>selector==='header'?header:null,
|
||||
addEventListener:(type,listener)=>documentListeners[type]=listener,
|
||||
}},
|
||||
history:{{state:null,pushState(state){{this.state=state;}},back(){{backs++;}}}},
|
||||
location:{{pathname:'/dashboard/',search:''}},
|
||||
addEventListener:(type,listener)=>windowListeners[type]=listener,
|
||||
}};
|
||||
const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}})}};
|
||||
(async()=>{{
|
||||
await attachSecurityCenter({{root,boundary}}).open();
|
||||
let prevented=false;
|
||||
documentListeners.keydown({{key:'Escape',preventDefault(){{prevented=true;}}}});
|
||||
const requested={{backs,prevented,hidden:ids['active-devices-sheet'].hidden,inert:header.inert}};
|
||||
windowListeners.popstate({{state:null}});
|
||||
console.log(JSON.stringify({{requested,closed:{{hidden:ids['active-devices-sheet'].hidden,inert:header.inert,focused}}}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"requested": {"backs": 1, "prevented": True, "hidden": False, "inert": True},
|
||||
"closed": {"hidden": True, "inert": False, "focused": "active-devices"},
|
||||
}
|
||||
@@ -168,13 +168,13 @@ async function dispatchPush(payload) {{
|
||||
def test_offline_activation_migration_rolls_the_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -183,7 +183,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-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" 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
|
||||
@@ -192,7 +192,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-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||
assert "BASE + 'static/issue-attachment.js'" in source
|
||||
|
||||
@@ -200,14 +200,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-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" 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-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -215,7 +215,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-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -223,7 +223,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-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" 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
|
||||
@@ -233,14 +233,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-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" 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-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -249,21 +249,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-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" 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-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" 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-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -1137,7 +1137,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-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -1159,6 +1159,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/icons/stackchain-192.png",
|
||||
"/dashboard/static/icons/stackchain-512.png",
|
||||
"/dashboard/static/session.js",
|
||||
"/dashboard/static/sign-out-review.js",
|
||||
"/dashboard/static/feature-loader.js",
|
||||
"/dashboard/static/workspace-bootstrap.js",
|
||||
"/dashboard/static/conversation-action-hydrator.js",
|
||||
@@ -1256,6 +1257,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/live-data-status.js",
|
||||
"/dashboard/static/mobile-today-command-bar.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-first-task.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-queue-launcher.js",
|
||||
"/dashboard/static/mobile-delivery-recovery.js",
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).parents[1] / "frontend" / "sign-out-review.js"
|
||||
|
||||
|
||||
def run_scenario(scenario: str, *, inventory: str = "{recordCount:3, unavailable:false}") -> dict:
|
||||
harness = r"""
|
||||
const createSignOutReview = require(__MODULE__);
|
||||
class Target {
|
||||
constructor() { this.listeners={}; this.hidden=false; this.textContent=''; this.disabled=false; this.dataset={}; }
|
||||
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
|
||||
async dispatch(name, event={}) { for (const callback of this.listeners[name] || []) await callback({preventDefault() { state.prevented = true; }, shiftKey:false, currentTarget:this, ...event}); }
|
||||
focus() { state.focused=this; }
|
||||
contains(target) { return target === this; }
|
||||
querySelectorAll() { return [cancelButton, confirmButton]; }
|
||||
}
|
||||
const state={signedOut:[], focused:null, historyPushes:0, historyBacks:0};
|
||||
const launcher=new Target();
|
||||
const allLauncher=new Target();
|
||||
const sheet=new Target(); sheet.hidden=true;
|
||||
const heading=new Target();
|
||||
const summary=new Target();
|
||||
const warning=new Target();
|
||||
const cancelButton=new Target();
|
||||
const confirmButton=new Target();
|
||||
const escapeTarget=new Target();
|
||||
const historyTarget=new Target();
|
||||
const background=new Target(); background.inert=false;
|
||||
const storage={
|
||||
values:new Map([['stackchain.private','secret'],['stackchain.draft','draft'],['gitea.preference','keep']]),
|
||||
get length() { return this.values.size; },
|
||||
key(index) { return Array.from(this.values.keys())[index] || null; },
|
||||
};
|
||||
const history={
|
||||
pushState() { state.historyPushes += 1; },
|
||||
back() { state.historyBacks += 1; historyTarget.dispatch('popstate'); },
|
||||
};
|
||||
const review=createSignOutReview({
|
||||
launcher, allLauncher, sheet, heading, summary, warning, cancelButton, confirmButton,
|
||||
escapeTarget, historyTarget, history, localStorage:storage, sessionStorage:storage,
|
||||
backgroundTargets:[background], getActiveElement:() => state.focused,
|
||||
privateDatabases:['private-work'], inspectPrivateDatabases:async () => (__INVENTORY__),
|
||||
onConfirm:async mode => { if (state.failConfirm) throw new Error('blocked purge'); state.signedOut.push(mode); },
|
||||
});
|
||||
(async () => { review.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
|
||||
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__INVENTORY__", inventory).replace("__SCENARIO__", scenario)
|
||||
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_private_work_is_counted_before_sign_out_and_requires_sheet_confirmation():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click');
|
||||
const before=[...state.signedOut];
|
||||
await confirmButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
before, after:state.signedOut, hidden:sheet.hidden, heading:heading.textContent,
|
||||
summary:summary.textContent, warning:warning.textContent, action:confirmButton.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"before": [],
|
||||
"after": ["current"],
|
||||
"hidden": True,
|
||||
"heading": "Review sign out",
|
||||
"summary": "This device has 2 private browser items and 3 private work records.",
|
||||
"warning": "Private drafts or queued work may not be synced. Signing out erases them from this device.",
|
||||
"action": "Sign out and erase private work",
|
||||
}
|
||||
|
||||
|
||||
def test_keyboard_focus_is_contained_inside_open_review():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click');
|
||||
state.focused=confirmButton;
|
||||
await escapeTarget.dispatch('keydown', {key:'Tab'});
|
||||
const wrappedForward=state.focused === cancelButton;
|
||||
state.focused=cancelButton;
|
||||
await escapeTarget.dispatch('keydown', {key:'Tab', shiftKey:true});
|
||||
process.stdout.write(JSON.stringify({wrappedForward, wrappedBackward:state.focused === confirmButton, prevented:state.prevented || false}));
|
||||
""")
|
||||
|
||||
assert result == {"wrappedForward": True, "wrappedBackward": True, "prevented": True}
|
||||
|
||||
|
||||
def test_cancel_removes_review_history_and_preserves_session_and_private_work():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click');
|
||||
await cancelButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
signedOut:state.signedOut, hidden:sheet.hidden, historyPushes:state.historyPushes,
|
||||
historyBacks:state.historyBacks, launcherFocused:state.focused === launcher,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"signedOut": [], "hidden": True, "historyPushes": 1,
|
||||
"historyBacks": 1, "launcherFocused": True,
|
||||
}
|
||||
|
||||
|
||||
def test_open_review_makes_background_inert_until_cancelled():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click');
|
||||
const inertWhileOpen=background.inert;
|
||||
await cancelButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({inertWhileOpen, inertAfterCancel:background.inert}));
|
||||
""")
|
||||
|
||||
assert result == {"inertWhileOpen": True, "inertAfterCancel": False}
|
||||
|
||||
|
||||
def test_sign_out_all_review_combines_global_session_and_local_work_warning():
|
||||
result = run_scenario("""
|
||||
await allLauncher.dispatch('click');
|
||||
const before=[...state.signedOut];
|
||||
await confirmButton.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
before, after:state.signedOut, heading:heading.textContent,
|
||||
warning:warning.textContent, action:confirmButton.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"before": [], "after": ["all"],
|
||||
"heading": "Review sign out on every device",
|
||||
"warning": "Every device will need to sign in again. Private drafts or queued work on this device may not be synced and will be erased.",
|
||||
"action": "Sign out and erase private work",
|
||||
}
|
||||
|
||||
|
||||
def test_failed_inventory_never_claims_the_device_is_empty():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click');
|
||||
process.stdout.write(JSON.stringify({
|
||||
signedOut:state.signedOut, hidden:sheet.hidden,
|
||||
summary:summary.textContent, warning:warning.textContent, action:confirmButton.textContent,
|
||||
}));
|
||||
""", inventory="Promise.reject(new Error('blocked'))")
|
||||
|
||||
assert result == {
|
||||
"signedOut": [], "hidden": False,
|
||||
"summary": "This device has 2 private browser items; private work status is unknown.",
|
||||
"warning": "Private drafts or queued work may not be synced. Signing out erases them from this device.",
|
||||
"action": "Sign out and erase private work",
|
||||
}
|
||||
|
||||
|
||||
def test_failed_purge_keeps_review_open_with_recovery_guidance():
|
||||
result = run_scenario("""
|
||||
await launcher.dispatch('click');
|
||||
state.failConfirm=true;
|
||||
try { await confirmButton.dispatch('click'); } catch (_error) {}
|
||||
process.stdout.write(JSON.stringify({
|
||||
signedOut:state.signedOut, hidden:sheet.hidden, disabled:confirmButton.disabled,
|
||||
warning:warning.textContent,
|
||||
}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"signedOut": [], "hidden": False, "disabled": False,
|
||||
"warning": "Sign out could not finish clearing private work. Close other Stackchain tabs, then retry.",
|
||||
}
|
||||
|
||||
|
||||
def test_dashboard_renders_and_wires_phone_safe_sign_out_review():
|
||||
root = MODULE.parents[1]
|
||||
html = (root / "frontend" / "index.html").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
session = (root / "frontend" / "session.js").read_text()
|
||||
review = MODULE.read_text()
|
||||
worker = (root / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert 'id="sign-out-review-sheet"' in html
|
||||
assert 'role="dialog" aria-modal="true" aria-labelledby="sign-out-review-heading"' in html
|
||||
assert 'id="sign-out-review-summary"' in html
|
||||
assert 'id="sign-out-review-warning"' in html
|
||||
assert 'id="cancel-sign-out"' in html
|
||||
assert 'id="confirm-sign-out"' in html
|
||||
assert html.index('static/private-data-inventory.js') < html.index('static/session.js')
|
||||
assert html.index('static/sign-out-review.js') < html.index('static/session.js')
|
||||
assert "root.createSignOutReview" in session
|
||||
assert "inspectPrivateDatabases: root.inspectStackchainPrivateDatabases" in review
|
||||
assert "stackchain-feature-sign-out" in session
|
||||
assert "max-height:100dvh" in css
|
||||
assert ".sign-out-review-actions button { min-height:44px" in css
|
||||
assert "env(safe-area-inset-bottom)" in css
|
||||
assert "BASE + 'static/sign-out-review.js'" in worker
|
||||
@@ -65,6 +65,34 @@ process.stdout.write(JSON.stringify(timer.snapshot()));
|
||||
}
|
||||
|
||||
|
||||
def test_reactivating_the_active_item_during_reload_preserves_its_timed_break():
|
||||
script = TIMER.read_text() + r"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||
let now = 100000;
|
||||
const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>now});
|
||||
timer.activate('issue:r:42:');
|
||||
timer.startBreak(5);
|
||||
const reopened = timer.activate('issue:r:42:');
|
||||
process.stdout.write(JSON.stringify({reopened,snapshot:timer.snapshot(),pending:timer.breakSnapshot()}));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"reopened": True,
|
||||
"snapshot": {
|
||||
"identity": "issue:r:42:",
|
||||
"elapsed_ms": 0,
|
||||
"running": False,
|
||||
"break_deadline_at": 400_000,
|
||||
},
|
||||
"pending": {
|
||||
"identity": "issue:r:42:",
|
||||
"deadline_at": 400_000,
|
||||
"expired": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_break_expiry_never_restarts_time_and_resume_is_explicit_and_idempotent():
|
||||
script = TIMER.read_text() + r"""
|
||||
const values = new Map();
|
||||
|
||||
@@ -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-v122';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v123';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -116,6 +116,109 @@ process.stdout.write(JSON.stringify({{
|
||||
assert output["keptAfterCopy"] is False
|
||||
|
||||
|
||||
def test_summary_validates_an_exact_gitea_thread_before_durable_posting():
|
||||
script = f"""
|
||||
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const resolved=[];
|
||||
const admitted=[];
|
||||
const summary=createSummary({{
|
||||
storage,getLogin:()=> 'timmy',createOperationId:()=> 'summary-op-1',
|
||||
resolveTarget:async target=>{{resolved.push(target);return{{...target,kind:'issue',title:'Daily status',state:'open'}};}},
|
||||
enqueueDurably:async message=>{{admitted.push(message);return{{item:message,durability:'background'}};}},
|
||||
}});
|
||||
summary.begin([{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',actual_minutes:42}}], []);
|
||||
const parsed=summary.setDestination(' StackChain/Status #42 ');
|
||||
const target=await summary.validateDestination();
|
||||
const result=await summary.postSummary();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
parsed,target,result,resolved,admitted,snapshot:summary.snapshot(),
|
||||
kept:values.has('stackchain.today-summary-draft.v1.timmy'),
|
||||
}}));
|
||||
"""
|
||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert output["parsed"] == {
|
||||
"repository": "stackchain/status", "number": 42, "label": "stackchain/status#42"
|
||||
}
|
||||
assert output["resolved"] == [{"repository": "stackchain/status", "number": 42}]
|
||||
assert output["target"] == {
|
||||
"repository": "stackchain/status", "number": 42, "kind": "issue",
|
||||
"title": "Daily status", "state": "open",
|
||||
}
|
||||
assert output["admitted"] == [{
|
||||
"kind": "search-reply", "repository": "stackchain/status", "number": 42,
|
||||
"targetKind": "issue", "body": "Today\n- Ship mobile capture",
|
||||
"operationId": "summary-op-1",
|
||||
}]
|
||||
assert output["result"]["status"] == "queued"
|
||||
assert output["result"]["durability"] == "background"
|
||||
assert output["snapshot"] is None
|
||||
assert output["kept"] is False
|
||||
|
||||
|
||||
def test_summary_keeps_reviewed_destination_and_operation_when_admission_fails():
|
||||
script = f"""
|
||||
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
const options={{
|
||||
storage,getLogin:()=> 'timmy',createOperationId:()=> 'stable-op',
|
||||
resolveTarget:async target=>({{...target,kind:'pull',title:'Release candidate',state:'open'}}),
|
||||
enqueueDurably:async()=>{{throw new Error('storage unavailable');}},
|
||||
}};
|
||||
const summary=createSummary(options);
|
||||
summary.begin([{{identity:'pull:acme/mobile:9:',title:'Review release',actual_minutes:18}}], []);
|
||||
summary.setDestination('acme/mobile#9');
|
||||
await summary.validateDestination();
|
||||
let error='';
|
||||
try {{ await summary.postSummary(); }} catch (caught) {{ error=caught.message; }}
|
||||
const restored=createSummary(options);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
error,restored:restored.restore(),snapshot:restored.snapshot(),text:restored.text(),
|
||||
}}));
|
||||
"""
|
||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert output["error"] == "storage unavailable"
|
||||
assert output["restored"] is True
|
||||
assert output["snapshot"]["operation_id"] == "stable-op"
|
||||
assert output["snapshot"]["target"]["kind"] == "pull"
|
||||
assert output["text"] == "Today\n- Review release"
|
||||
|
||||
|
||||
def test_summary_coalesces_concurrent_post_taps_into_one_durable_admission():
|
||||
script = f"""
|
||||
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
|
||||
let release;
|
||||
const gate=new Promise(resolve=>release=resolve);
|
||||
const admitted=[];
|
||||
const summary=createSummary({{
|
||||
getLogin:()=> 'timmy',createOperationId:()=> 'one-operation',
|
||||
resolveTarget:async target=>({{...target,kind:'issue',title:'Status',state:'open'}}),
|
||||
enqueueDurably:async message=>{{admitted.push(message);await gate;return{{item:message,durability:'background'}};}},
|
||||
}});
|
||||
summary.begin([{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',actual_minutes:42}}], []);
|
||||
summary.setDestination('acme/mobile#41');
|
||||
await summary.validateDestination();
|
||||
const first=summary.postSummary();
|
||||
const second=summary.postSummary();
|
||||
release();
|
||||
const results=await Promise.all([first,second]);
|
||||
process.stdout.write(JSON.stringify({{admitted,results}}));
|
||||
"""
|
||||
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
|
||||
assert len(output["admitted"]) == 1
|
||||
assert output["results"][0] == output["results"][1]
|
||||
|
||||
|
||||
def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet():
|
||||
from src import main
|
||||
|
||||
@@ -130,13 +233,21 @@ def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet()
|
||||
assert 'id="today-summary-tomorrow"' in html
|
||||
assert 'id="today-summary-include-actuals"' in html
|
||||
assert 'id="share-today-summary"' in html
|
||||
assert 'id="today-summary-destination"' in html
|
||||
assert 'id="validate-today-summary-destination"' in html
|
||||
assert 'id="post-today-summary"' in html
|
||||
assert 'id="today-summary-target"' in html
|
||||
assert "static/today-summary.js" in main.FRONTEND_BUILD.page_sources
|
||||
assert "setupTodaySummary({" in dashboard
|
||||
assert "enqueueDurably:message => authoredOutbox.enqueueDurably(message)" in dashboard
|
||||
assert "fetchJson:api" in dashboard
|
||||
assert "/preview?kind=issue" in (ROOT / "frontend" / "today-summary.js").read_text()
|
||||
assert "todaySummaryView.open(workedItems, tomorrowItems)" in dashboard
|
||||
assert "openWrapUp(handoff.actual_minutes, workedItems)" in recap
|
||||
assert "onComplete(result, actualMinutes, workedItems, tomorrowItems)" in wrap_up
|
||||
assert ".today-summary-actions button { min-height:44px;" in css
|
||||
assert ".today-summary-panel" in css and "overflow-x:hidden" in css
|
||||
assert ".today-summary-destination" in css and "overflow-wrap:anywhere" in css
|
||||
assert "tests/e2e/test_mobile_today_summary_release.py" in (
|
||||
ROOT / ".gitea" / "workflows" / "ci.yml"
|
||||
).read_text()
|
||||
|
||||
@@ -191,7 +191,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-v122" in source
|
||||
assert "stackchain-dashboard-shell-v123" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -391,6 +391,35 @@ process.stdout.write(JSON.stringify({interruption, paused, returned, resumed, re
|
||||
}
|
||||
|
||||
|
||||
def test_live_data_status_detour_excludes_troubleshooting_time_and_resumes_once():
|
||||
script = SOURCE.read_text() + r"""
|
||||
const values = new Map();
|
||||
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||
let now = 1000;
|
||||
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
|
||||
timer.activate('issue:r:42:');
|
||||
now = 6000;
|
||||
const interruption = timer.beginDetour('live-data-status');
|
||||
now = 26000;
|
||||
const paused = timer.snapshot();
|
||||
const returned = timer.returnFromDetour('live-data-status');
|
||||
now = 28000;
|
||||
const resumed = timer.snapshot();
|
||||
const repeated = timer.returnFromDetour('live-data-status');
|
||||
process.stdout.write(JSON.stringify({interruption, paused, returned, resumed, repeated}));
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"interruption": {"identity": "issue:r:42:", "resume": True, "reason": "live-data-status"},
|
||||
"paused": {"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
|
||||
"returned": {"identity": "issue:r:42:", "resumed": True, "reason": "live-data-status"},
|
||||
"resumed": {"identity": "issue:r:42:", "elapsed_ms": 7000, "running": True},
|
||||
"repeated": None,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_detour_view_names_work_and_returns_to_today():
|
||||
script = SOURCE.read_text() + r"""
|
||||
const values = new Map();
|
||||
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
|
||||
|
||||
BOOTSTRAP = Path(__file__).parents[1] / "frontend" / "workspace-bootstrap.js"
|
||||
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
|
||||
|
||||
def run_bootstrap(scenario: str) -> dict:
|
||||
@@ -35,33 +37,71 @@ 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():
|
||||
def test_workspace_bootstrap_recovers_one_transient_failure_in_place():
|
||||
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 status={textContent:''}; const retry={hidden:true,disabled:false}; let attempts=0;
|
||||
const document={querySelector(selector) {
|
||||
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
|
||||
if (selector === '#my-work-action-status') return status;
|
||||
if (selector === '#retry-workspace') return retry;
|
||||
return null;
|
||||
}};
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts === 1) throw new Error('brief outage');}});
|
||||
const schedule=callback=>{callback();};
|
||||
await loadWorkspace({document,createLoader,schedule});
|
||||
console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden}));
|
||||
""")
|
||||
assert result == {"attempts": 2, "status": "", "retryHidden": True}
|
||||
|
||||
|
||||
def test_workspace_bootstrap_offers_single_flight_manual_retry_without_reloading():
|
||||
result = run_bootstrap("""
|
||||
const status={textContent:''}; const listeners={}; let reloads=0; let attempts=0;
|
||||
const retry={hidden:true,disabled:false,addEventListener(name,callback){listeners[name]=callback;}};
|
||||
const document={querySelector(selector) {
|
||||
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
|
||||
if (selector === '#my-work-action-status') return status;
|
||||
if (selector === '#retry-workspace') return retry;
|
||||
return null;
|
||||
}};
|
||||
const window={location:{reload(){reloads++;}},addEventListener(){},removeEventListener(){}};
|
||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
|
||||
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
const offered={hidden:retry.hidden,disabled:retry.disabled,status:status.textContent};
|
||||
const first=listeners.click(); const second=listeners.click();
|
||||
await Promise.all([first,second,loading]);
|
||||
console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
|
||||
""")
|
||||
assert result == {
|
||||
"attempts": 3,
|
||||
"reloads": 0,
|
||||
"offered": {
|
||||
"hidden": False,
|
||||
"disabled": False,
|
||||
"status": "Workspace unavailable. Reconnect or retry.",
|
||||
},
|
||||
"status": "",
|
||||
"retryHidden": True,
|
||||
}
|
||||
|
||||
|
||||
def test_workspace_retry_control_is_accessible_and_touch_safe():
|
||||
index = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert '<button class="retry-work-route" id="retry-workspace" type="button" hidden>Retry workspace</button>' in index
|
||||
assert ".retry-work-route { min-height:44px;" in css
|
||||
|
||||
|
||||
def test_workspace_bootstrap_reconnect_retries_in_place_without_reloading():
|
||||
result = run_bootstrap("""
|
||||
const status={textContent:''}; const listeners={}; let reloads=0; let attempts=0;
|
||||
const retry={hidden:true,disabled:false,addEventListener(){},removeEventListener(){}};
|
||||
const document={querySelector(selector) {
|
||||
if (selector.startsWith('meta[')) return {content:'feature-workspace-abc.js'};
|
||||
if (selector === '#my-work-action-status') return status;
|
||||
if (selector === '#retry-workspace') return retry;
|
||||
return null;
|
||||
}};
|
||||
const window={
|
||||
@@ -69,39 +109,22 @@ const window={
|
||||
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 createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
|
||||
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
const waiting=Boolean(listeners.online);
|
||||
if (listeners.online) listeners.online();
|
||||
console.log(JSON.stringify({waiting,reloads,status:status.textContent}));
|
||||
await loading;
|
||||
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"attempts": 3,
|
||||
"waiting": True,
|
||||
"reloads": 1,
|
||||
"status": "Workspace could not load. Reconnect to retry automatically, or reload now.",
|
||||
"reloads": 0,
|
||||
"status": "",
|
||||
}
|
||||
|
||||
|
||||
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={};
|
||||
|
||||
Reference in new issue
Block a user