Compare commits

...
Author SHA1 Message Date
timmy 12a7a3c4b0 Merge pull request 'feat: Restore bare-environment full-suite cache-policy test' (#1474) from timmy/1473-restore-bare-environment-full-suite-cache-policy into main
CI / lint (push) Successful in 3m49s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m27s
CI / release-candidate (push) Successful in 7s
2026-08-27 16:49:42 +00:00
timmy 454e2dcffc test: restore cache-policy suite setup (Closes #1473)
CI / lint (pull_request) Successful in 3m43s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
CI / browser-journey (pull_request) Successful in 7m33s
2026-08-27 16:16:52 +00:00
rockachopa 0e70fb9b62 Merge pull request 'Reopen recent work from mobile Queues' (#1472) from timmy/1471-mobile-recent-work into main
CI / lint (push) Successful in 3m44s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m32s
CI / release-candidate (push) Successful in 7s
2026-08-27 15:53:08 +00:00
timmy eb101bf5ce feat: reopen recent work from mobile queues (Closes #1471)
CI / lint (pull_request) Successful in 3m39s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m53s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 15:40:41 +00:00
rockachopa 46e06c1a37 Merge pull request 'Demand-load Today and Planning workspace on mobile' (#1469) from timmy/1468-demand-load-mobile-workspace into main
CI / lint (push) Successful in 3m48s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m30s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Demand-load Today and Planning workspace on mobile' (#1469)
2026-08-27 14:46:10 +00:00
timmy 02b9fdd2e4 fix: finish identity recovery before workspace readiness
CI / lint (pull_request) Successful in 3m45s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 7m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 14:34:13 +00:00
timmy 5ccf499b1a test: isolate workspace retry from service worker
CI / lint (pull_request) Successful in 4m3s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 7m38s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 13:10:30 +00:00
timmy e8b2f31e5e fix: restore hydrated session state before action replay
CI / lint (pull_request) Successful in 3m57s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 7m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 12:27:44 +00:00
timmy c1d719842f test: await hydrated state in reload journeys
CI / lint (pull_request) Successful in 3m52s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 6m56s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 12:25:22 +00:00
timmy 4a99f54f12 test: hydrate demand workspace after release reloads
CI / lint (pull_request) Successful in 3m56s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 7m16s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 12:22:42 +00:00
timmy 8909bedd78 test: align packaged journeys with demand hydration
CI / lint (pull_request) Successful in 4m2s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 7m47s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 12:17:20 +00:00
timmy 5d9cec1dd7 perf: demand-load mobile workspace features (Closes #1468)
CI / lint (pull_request) Successful in 3m49s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 9m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 11:47:09 +00:00
timmy 2a44cf20fc Merge pull request 'Make mobile queue priority self-healing across devices' (#1467) from timmy/1466-self-healing-queue-priority into main
CI / lint (push) Successful in 3m37s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m31s
CI / release-candidate (push) Successful in 7s
2026-08-27 10:43:43 +00:00
timmy 1e8d27c18f feat: make mobile queue priority self-healing (Closes #1466)
CI / lint (pull_request) Successful in 3m44s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 7m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-27 10:32:02 +00:00
timmy c5b801460d Merge pull request 'Sync personalized mobile queue priority across devices' (#1465)
CI / lint (push) Successful in 3m27s
CI / build-release (push) Successful in 5s
CI / browser-journey (push) Successful in 7m27s
CI / release-candidate (push) Successful in 7s
Closes #1464
2026-08-27 09:39:51 +00:00
27 changed files with 808 additions and 93 deletions

No files matched your search

+6 -4
View File
@@ -122,12 +122,14 @@ sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to ov
`.stackchain-state/saved-searches.sqlite3` path.
The mobile **Customize routine order** control is also portable across authenticated devices. Reordering
remains immediate when offline and is marked **Sync pending** until connectivity returns. The complete
remains immediate when offline and is marked **Sync pending** until connectivity returns. Rapid taps are
coalesced into one latest-order write; transient network or server failures retry automatically with bounded
backoff, and a clean phone refreshes the account order when it returns to the foreground. The complete
routine order is stored as an encrypted, revisioned collection scoped to the confirmed Gitea login; a
concurrent edit shows explicit **Keep this device** and **Use other device** actions instead of silently
losing either order. Resetting publishes the canonical default order. Delivery, Human Gates, and active
Prepare Today precedence are not customizable. Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the
default `.stackchain-state/queue-priority.sqlite3` path.
losing either order. Account changes discard stale responses and retry work. Resetting publishes the
canonical default order. Delivery, Human Gates, and active Prepare Today precedence are not customizable.
Set `STACKCHAIN_QUEUE_PRIORITY_DB` to override the default `.stackchain-state/queue-priority.sqlite3` path.
Confirmed **Watch issue** and **Watch pull request** actions on open Search results and assigned My Work
issue/pull-request details feed the mobile **Following** queue, including work already assigned to you or a teammate.
+1 -1
View File
@@ -126,7 +126,7 @@ function createContextPoller({
let request;
try {
request = fetchContext({ ...revisions }, { signal: controller.signal });
request = fetchContext(options.full ? {} : { ...revisions }, { signal: controller.signal });
} catch (error) {
request = Promise.reject(error);
}
+1
View File
@@ -1551,6 +1551,7 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
.mobile-queue-list small { color:var(--muted); }
.mobile-queue-list [data-recent-work-route] { min-height:56px; overflow-wrap:anywhere; }
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
+45 -5
View File
@@ -115,6 +115,7 @@
let offlineWorkMode = false;
let renderMobileQueuePresentation = () => {};
let mobileQueuePriority = null;
let mobileRecentWork = { record:() => false, render:() => 0 };
const followingQueue = attachFollowing(item => {
searchPreviewReturnKind = 'following';
return searchPreview.open(item);
@@ -313,7 +314,7 @@
find: () => qs('#find-work').click(),
new: () => qs('#new-issue').click(),
search: () => qs('#open-palette').click(),
queues: () => refreshTomorrowQueueSummary(),
queues: () => { refreshTomorrowQueueSummary(); mobileRecentWork.render(); },
},
observe(callback, overlays) {
const observer = new MutationObserver(callback);
@@ -358,6 +359,7 @@
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
let liveMode = true;
let initialAccountRecovery = Promise.resolve(false);
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
const WORK_FILTERS = ['all', 'today', 'agenda', 'attention', 'filed', 'authored', 'issue', 'pull', 'review', 'update', 'later', 'draft'];
@@ -430,6 +432,19 @@
let planningOwnerLogin = '';
let planningOwnerAccountKey = '';
let activeFlushLogin = '';
mobileRecentWork = createMobileRecentWork({
storage:localStorage,
getLogin:() => confirmedOwnerLogin,
document,
section:qs('#mobile-recent-work'),
list:qs('#mobile-recent-work-list'),
openRoute:fragment => {
const sheet = qs('#mobile-queue-sheet');
if (sheet.open) sheet.close();
if (window.location.hash !== fragment) window.history.pushState({ workRoute:fragment }, '', fragment);
workRoute.sync();
},
});
mobileQueuePriority = createMobileQueuePriority({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
@@ -447,11 +462,10 @@
},
onChange: () => {
renderMobileQueuePresentation();
void mobileQueuePriority.sync();
},
});
mobileQueuePriority.start();
window.addEventListener('online', () => { void mobileQueuePriority.sync(); });
mobileQueuePriority.startLifecycle({window, document});
let rR = null;
function rRC() {
if (rR) return rR;
@@ -2061,6 +2075,7 @@
qs('#my-work-action-status').textContent = '';
closeOpenWorkSheets();
await openRoutedWorkSection(item);
mobileRecentWork.record(item);
const route = createWorkRoute.parse(window.location.hash);
if (route?.section === item.section) navigateWorkSection(item.kind, item.section);
},
@@ -5645,6 +5660,9 @@
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
if (activeFlushLogin) {
confirmedOwnerLogin = activeFlushLogin;
planningOwnerLogin = activeFlushLogin;
planningOwnerAccountKey = snapshot.context.user?.id ?
String(snapshot.context.user.id) + ':' + activeFlushLogin : '';
mobileQueuePriority.render();
renderMobileQueuePresentation();
void mobileQueuePriority.load();
@@ -5667,7 +5685,10 @@
if (planningOwnerLogin) {
syncPendingTomorrow();
todaySync.migrate(todayWork.read());
todaySync.flush();
initialAccountRecovery = Promise.all([
initialAccountRecovery,
todaySync.flush(),
]).then(() => true);
laterSync.migrate(laterWork.read());
laterSync.flush();
}
@@ -8473,7 +8494,8 @@
});
let pushControllerReady = Promise.resolve(null);
if ('serviceWorker' in navigator) {
pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => {
pushControllerReady = (workspaceLifecycle.serviceWorkerReady ||
navigator.serviceWorker.register('service-worker.js')).then(async () => {
await issueCaptureFeatures.load('push-notifications');
const controller = createPushNotifications({
control:qs('#push-updates'),
@@ -8597,6 +8619,20 @@
adoptedProgressiveSnapshot = await contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise);
}
if (!adoptedProgressiveSnapshot) await load();
else if (!confirmedOwnerLogin) {
await contextPoller.refresh({ force:true, full:true });
if (!confirmedOwnerLogin) {
try {
const identity = await fetchReviewJson('api/v1/background-identity');
const login = String(identity?.login || '').trim();
if (login) {
confirmedOwnerLogin = login;
planningOwnerLogin = login;
initialAccountRecovery = timerView.restore(todaySync.flush());
}
} catch (_error) {}
}
}
if (progressiveWorkHandoff?.openWork) {
const progressiveItem = lastMyWork.find(item =>
item.repository === progressiveWorkHandoff.openWork.repository &&
@@ -8615,4 +8651,8 @@
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
setInterval(widgetTick, 1000);
await initialAccountRecovery;
if (planningOwnerLogin) await todaySync.flush();
await timerView.restore(Promise.resolve(true));
workspaceLifecycle.markWorkspaceReady?.();
})();
+5
View File
@@ -2183,6 +2183,10 @@
<p class="small muted" id="mobile-queue-next-heading">Start / Continue</p>
<button id="mobile-queue-next-action" type="button">Find Work</button>
</section>
<section class="mobile-queue-group" id="mobile-recent-work" aria-labelledby="mobile-recent-work-heading" hidden>
<h3 id="mobile-recent-work-heading">Recent work</h3>
<div class="mobile-queue-list" id="mobile-recent-work-list"></div>
</section>
<details class="mobile-queue-priority" id="mobile-queue-priority">
<summary>Customize routine order</summary>
<p class="small muted">Delivery and Human Gates always stay first. Move the routine queues to match how you work.</p>
@@ -2416,6 +2420,7 @@
<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-recent-work.js"></script>
<script src="static/mobile-queue-priority.js"></script>
<script src="static/mobile-queue-launcher.js"></script>
<script src="static/mobile-delivery-recovery.js"></script>
+70 -1
View File
@@ -11,8 +11,17 @@
const prefix = 'stackchain-mobile-queue-priority-v1:';
const labels = options.labels || {};
const documentRef = options.document || (typeof document !== 'undefined' ? document : null);
const setTimer = options.setTimeout || setTimeout;
const clearTimer = options.clearTimeout || clearTimeout;
const debounceMs = Number.isFinite(options.debounceMs) ? Math.max(0, options.debounceMs) : 150;
const retryBaseMs = Number.isFinite(options.retryBaseMs) ? Math.max(1, options.retryBaseMs) : 1000;
const retryMaxMs = Number.isFinite(options.retryMaxMs) ? Math.max(retryBaseMs, options.retryMaxMs) : 30000;
let memory = null;
const syncFlights = new Map();
let debounceTimer = null;
let retryTimer = null;
let retryAccount = '';
let retryAttempts = 0;
function key() {
const login = String(getLogin() || '').trim().toLowerCase();
@@ -90,6 +99,45 @@
if (!persist(value)) return false;
options.onChange?.(order.slice());
announce(value);
scheduleSync();
return true;
}
function scheduleSync() {
if (!fetchJson || !key()) return false;
if (debounceTimer) clearTimer(debounceTimer);
debounceTimer = setTimer(() => {
debounceTimer = null;
void sync();
}, debounceMs);
return true;
}
function clearRetry() {
if (retryTimer) clearTimer(retryTimer);
retryTimer = null;
retryAccount = '';
retryAttempts = 0;
}
function transient(error) {
const status = Number(error?.status) || 0;
return status === 0 || status === 408 || status === 425 || status === 429 || status >= 500;
}
function scheduleRetry(accountKey) {
if (retryTimer && retryAccount !== accountKey) clearRetry();
if (retryTimer || key() !== accountKey) return false;
const delay = Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempts));
retryAttempts += 1;
retryAccount = accountKey;
retryTimer = setTimer(() => {
retryTimer = null;
retryAccount = '';
const current = read();
if (key() !== accountKey || !current.pending || current.status === 'conflict') return;
void sync();
}, delay);
return true;
}
@@ -123,6 +171,7 @@
throw new Error('Queue priority response is invalid.');
}
const value = {revision:snapshotValue.revision, order:snapshotValue.order.slice(), pending:false, status:'ready', remote:null};
clearRetry();
persist(value);
options.onChange?.(value.order.slice());
announce(value);
@@ -180,10 +229,12 @@
const latest = read();
const remote = error?.status === 409 && error?.payload?.detail?.snapshot;
if (remote && valid(remote.order) && Number.isInteger(remote.revision)) {
clearRetry();
latest.status = 'conflict'; latest.pending = true;
latest.remote = {revision:remote.revision, order:remote.order.slice()};
} else {
latest.status = 'pending'; latest.pending = true;
if (transient(error)) scheduleRetry(accountKey);
}
persist(latest); announce(latest); render();
return snapshot();
@@ -193,6 +244,10 @@
}
function sync() {
if (debounceTimer) {
clearTimer(debounceTimer);
debounceTimer = null;
}
const accountKey = key();
const current = read();
if (!fetchJson || !accountKey || !current.pending) return Promise.resolve(snapshot());
@@ -266,6 +321,20 @@
return render();
}
return {getOrder, move, reset, render, start, load, sync, useLocal, useRemote,
function startLifecycle(lifecycle = {}) {
const windowObject = lifecycle.window;
const lifecycleDocument = lifecycle.document;
const reconcile = () => {
if (!key()) return Promise.resolve(snapshot());
return read().pending ? sync() : load();
};
windowObject?.addEventListener?.('online', () => { void reconcile(); });
lifecycleDocument?.addEventListener?.('visibilitychange', () => {
if (!lifecycleDocument.hidden) void reconcile();
});
return reconcile;
}
return {getOrder, move, reset, render, start, startLifecycle, load, sync, scheduleSync, useLocal, useRemote,
state:snapshot, defaultOrder:() => DEFAULT_ORDER.slice()};
});
+102
View File
@@ -0,0 +1,102 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileRecentWork = factory;
})(typeof globalThis !== 'undefined' ? globalThis : this, function createMobileRecentWork(options) {
'use strict';
const storage = options.storage;
const getLogin = options.getLogin;
const limit = Math.max(1, Number(options.limit) || 5);
const prefix = 'stackchain.mobile-recent-work.v1.';
const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const kinds = new Set(['issue', 'filed', 'pull', 'review', 'update']);
function login() {
return String(getLogin?.() || '').trim().toLowerCase();
}
function key() {
const owner = login();
return owner ? prefix + owner : '';
}
function normalize(item) {
const kind = String(item?.kind || '');
const number = Number(item?.number ?? item?.notification_id);
if (!kinds.has(kind) || !Number.isSafeInteger(number) || number < 1) return null;
let route = '';
let repository = '';
if (kind === 'update') {
route = '#/my-work/update/' + number;
} else {
repository = String(item?.repository || '');
if (!repositoryPattern.test(repository)) return null;
route = '#/my-work/' + kind + '/' + repository + '/' + number;
}
const title = String(item?.title || item?.subject?.title || '').trim().slice(0, 180);
if (!title) return null;
return {
kind,
...(repository ? { repository } : {}),
number,
title,
route,
};
}
function items() {
const storageKey = key();
if (!storageKey) return [];
try {
const parsed = JSON.parse(storage.getItem(storageKey) || '[]');
if (!Array.isArray(parsed)) return [];
return parsed.map(normalize).filter(Boolean).slice(0, limit);
} catch (_) {
return [];
}
}
function record(item) {
const storageKey = key();
const normalized = normalize(item);
if (!storageKey || !normalized) return false;
const next = [normalized, ...items().filter(existing => existing.route !== normalized.route)].slice(0, limit);
try {
storage.setItem(storageKey, JSON.stringify(next));
return true;
} catch (_) {
return false;
}
}
function render() {
const recent = items();
const list = options.list;
const section = options.section;
if (!list || !section || !options.document) return recent.length;
const rows = recent.map(item => {
const detail = item.kind === 'update'
? 'Update · #' + item.number
: item.kind.charAt(0).toUpperCase() + item.kind.slice(1) + ' · ' + item.repository + ' #' + item.number;
const button = options.document.createElement('button');
const copy = options.document.createElement('span');
const primary = options.document.createElement('strong');
const secondary = options.document.createElement('small');
primary.textContent = item.title;
secondary.textContent = detail;
copy.appendChild(primary);
copy.appendChild(secondary);
button.appendChild(copy);
button.setAttribute('type', 'button');
button.setAttribute('data-recent-work-route', item.route);
button.setAttribute('aria-label', 'Open ' + item.title + ', ' + detail.toLowerCase().replace(' · ', ' '));
button.addEventListener('click', () => options.openRoute?.(item.route));
return button;
});
list.replaceChildren(...rows);
section.hidden = rows.length === 0;
return rows.length;
}
return { items, record, render };
});
+5 -1
View File
@@ -270,6 +270,7 @@ const SHELL = [
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-first-task.js',
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-recent-work.js',
BASE + 'static/mobile-queue-priority.js',
BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/mobile-delivery-recovery.js',
@@ -301,6 +302,8 @@ const SHELL = [
];
const OPTIONAL_FEATURES = [
];
const DEMAND_FEATURES = [
];
const SHARED_IMAGE_ID = 'shared-image';
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
@@ -1027,7 +1030,8 @@ self.addEventListener('fetch', event => {
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
return;
}
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
if (url.origin === self.location.origin &&
(OPTIONAL_FEATURES.includes(url.pathname) || DEMAND_FEATURES.includes(url.pathname))) {
event.respondWith(cachedOptionalFeature(request));
}
});
+4 -1
View File
@@ -489,7 +489,10 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
const snapshot = timer.snapshot();
breakView?.render();
const active = Boolean(progress && isActive() && snapshot.identity);
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
queryAll('[data-mobile-today-hud]').forEach(element => {
const coaching = element.querySelector?.('[data-mobile-first-task-coach]:not([hidden])');
element.hidden = !active && !coaching;
});
queryAll('[data-mobile-today-open]').forEach(element => {
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
});
+59 -15
View File
@@ -19,6 +19,15 @@ async function loadWorkspace({
const failed = new Set();
const recoveries = new Map();
let retryInFlight = null;
let resolveWorkspaceReady;
const workspaceReady = new Promise(resolve => { resolveWorkspaceReady = resolve; });
let workspaceMarkedReady = false;
const markWorkspaceReady = () => {
if (workspaceMarkedReady) return false;
workspaceMarkedReady = true;
resolveWorkspaceReady(true);
return true;
};
const loadFeature = name => {
attempts[name] += 1;
@@ -63,6 +72,8 @@ async function loadWorkspace({
retryButton?.addEventListener?.('click', retryFailed);
const handleOnline = () => { cameOnline = true; void retryFailed(); };
window?.addEventListener('online', handleOnline);
const serviceWorkerReady = window?.navigator?.serviceWorker?.register ?
window.navigator.serviceWorker.register('service-worker.js') : Promise.resolve(null);
try {
await retryOnce('work-core');
@@ -73,23 +84,56 @@ async function loadWorkspace({
}
hideRecovery();
const optional = ['today-timer', 'planning'].map(async name => {
try {
await retryOnce(name);
return true;
} catch (_error) {
failed.add(name);
showRecovery();
return new Promise(resolve => recoveries.set(name, {resolve}));
}
});
const optionalReady = Promise.all(optional).then(() => {
hideRecovery();
return true;
});
let optionalReady = null;
const hydrateWorkspace = () => {
if (optionalReady) return optionalReady;
let resolveOptional;
optionalReady = new Promise(resolve => { resolveOptional = resolve; });
const optional = ['today-timer', 'planning'].map(async name => {
try {
await retryOnce(name);
return true;
} catch (_error) {
failed.add(name);
showRecovery();
return new Promise(resolve => recoveries.set(name, {resolve}));
}
});
Promise.all(optional).then(() => {
hideRecovery();
resolveOptional(true);
});
return optionalReady;
};
const hydrationSelector = [
'[data-mobile-task]:not([data-mobile-task="queues"])',
'[data-progressive-loading="true"]',
'#app-menu-toggle',
'#work-settings-toggle',
].join(',');
const hydrateForAction = async event => {
const target = event.target?.closest?.(hydrationSelector);
if (!target) return;
event.preventDefault();
event.stopImmediatePropagation();
await hydrateWorkspace();
await workspaceReady;
document.removeEventListener?.('click', hydrateForAction, true);
target.click?.();
};
document.addEventListener?.('click', hydrateForAction, true);
const hash = window?.location?.hash || '';
const deepLinkReady = hash.startsWith('#/') && hash !== '#/my-work' ?
hydrateWorkspace() : Promise.resolve(false);
return {
optionalReady,
hydrateWorkspace,
deepLinkReady,
workspaceReady,
markWorkspaceReady,
serviceWorkerReady,
get optionalReady() { return hydrateWorkspace(); },
retryFeature(name) {
if (!failed.has(name)) return Promise.resolve(true);
return retryFailed().then(() => !failed.has(name));
+16 -2
View File
@@ -44,7 +44,7 @@ FEATURE_SOURCES = {
),
"today-timer": (
"static/mobile-app-badge.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-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-search-modal.js", "static/mobile-composer-viewport.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/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/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-priority.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-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/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/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-recent-work.js", "static/mobile-queue-priority.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/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.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",
@@ -140,7 +140,16 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
for source in sources:
if source != WORKER_RUNTIME_SOURCE:
worker = worker.replace(f" BASE + '{source}',\n", "")
optional_features = feature_bundles
# The workspace hydrator fetches these large chunks only when a route or action
# needs them; warming them here would defeat demand loading on every visit.
optional_features = {
name: bundle for name, bundle in feature_bundles.items()
if name not in {"today-timer", "planning"}
}
demand_features = {
name: bundle for name, bundle in feature_bundles.items()
if name in {"today-timer", "planning"}
}
worker = worker.replace(
" BASE + 'static/dashboard.css',\n",
" BASE + 'static/dashboard.css',\n"
@@ -151,6 +160,11 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
"const OPTIONAL_FEATURES = [\n"
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
)
worker = worker.replace(
"const DEMAND_FEATURES = [\n",
"const DEMAND_FEATURES = [\n"
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in demand_features.values()),
)
worker = CACHE_DECLARATION.sub(
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
)
+14 -3
View File
@@ -13,7 +13,7 @@ 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
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
@@ -85,6 +85,7 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
hydrate_workspace(page)
page.evaluate("document.querySelector('#open-human-gates').click()")
expect(page.locator("#human-gates")).to_be_visible()
@@ -126,6 +127,8 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
popup.close()
page.reload(wait_until="networkidle")
hydrate_workspace(page)
page.wait_for_load_state("networkidle")
page.evaluate("document.querySelector('#open-human-gates').click()")
expect(page.locator("#human-gates")).to_be_visible()
expect(page.locator('[data-gate-checklist="exact_hash"]')).to_be_checked()
@@ -147,6 +150,8 @@ def test_release_artifact_reopens_human_gates_with_one_fresh_mobile_snapshot(
assert len(decision_requests) == 1
page.reload(wait_until="networkidle")
hydrate_workspace(page)
page.wait_for_load_state("networkidle")
page.evaluate("document.querySelector('#open-human-gates').click()")
expect(page.locator("#human-gates")).to_be_visible()
expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown")
@@ -244,9 +249,13 @@ def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(t
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
hydrate_workspace(page)
page.wait_for_load_state("networkidle")
page.evaluate("document.querySelector('#open-human-gates').click()")
expect(page.locator("#human-gates")).to_be_visible()
cached_before = page.evaluate("Object.keys(localStorage).sort()")
cached_before = page.evaluate(
"Object.keys(localStorage).filter(key => key.includes('human-gate')).sort()"
)
page.locator("#human-gates-history").click()
expect(page.locator("#human-gates-status")).to_contain_text("Older decisions are available")
@@ -264,7 +273,9 @@ def test_release_artifact_reviews_live_human_gate_history_and_receipt_on_phone(t
expect(page.locator("#human-gate-detail")).to_contain_text("receipt-2")
expect(page.locator("#human-gates-status")).to_have_text("All 3 Human Gate decisions loaded.")
expect(page.locator("[data-human-gate-history-more]")).to_have_count(0)
assert page.evaluate("Object.keys(localStorage).sort()") == cached_before
assert page.evaluate(
"Object.keys(localStorage).filter(key => key.includes('human-gate')).sort()"
) == cached_before
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
for selector in ("#human-gates-pending", "#human-gates-history"):
bounds = page.locator(selector).bounding_box()
+88 -17
View File
@@ -41,6 +41,10 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
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(
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
)
expect(page.locator("#my-work-status")).to_contain_text("No assigned work")
page.locator('[data-mobile-task="work"]').click()
@@ -56,6 +60,7 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
expect(sheet).to_be_hidden()
expect(page.locator("#find-work-sheet")).to_be_visible()
page.locator("#close-find-work").click()
expect(page.locator("#find-work-sheet")).to_be_hidden()
context.set_offline(True)
page.locator('[data-mobile-task="work"]').click()
@@ -66,8 +71,9 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
expect(page.locator("#mobile-first-task-create")).to_be_focused()
page.locator("#close-mobile-first-task").click()
expect(sheet).to_be_hidden()
context.set_offline(False)
page.evaluate(
probe_result = page.evaluate(
"""() => {
document.querySelector('[data-mobile-today-hud]').hidden = false;
localStorage.setItem('stackchain.first-task.v1:timmy', 'coaching');
@@ -78,17 +84,54 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
getLogin: () => 'timmy', hasWork: () => true, isTodayActive: () => true,
coach: isolatedCoach,
});
return window.firstTaskOutcomeProbe.refresh();
return {
result: window.firstTaskOutcomeProbe.refresh(),
state: localStorage.getItem('stackchain.first-task.v1:timmy'),
mobile: matchMedia('(max-width: 600px)').matches,
};
}"""
)
assert probe_result == {"result": "coaching", "state": "coaching", "mobile": True}
coach = page.locator("[data-mobile-first-task-coach]")
expect(coach).to_be_visible()
try:
expect(coach).to_be_visible()
except AssertionError as error:
raise AssertionError(page.evaluate("""() => {
const coach = document.querySelector('[data-mobile-first-task-coach]');
const hud = coach.closest('[data-mobile-today-hud]');
return {
coachHidden: coach.hidden,
coachDisplay: getComputedStyle(coach).display,
hudHidden: hud.hidden,
hudOverlay: hud.getAttribute('data-overlay-hidden'),
hudDisplay: getComputedStyle(hud).display,
open: Array.from(document.querySelectorAll('dialog, .open'))
.filter(element => element.open || element.classList.contains('open'))
.map(element => element.id || element.className),
};
}""")) from error
expect(coach).to_contain_text("Complete your first task")
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
assert page.evaluate("window.firstTaskOutcomeProbe.completeOutcome()") is True
completion = page.evaluate("""() => {
const completed = window.firstTaskOutcomeProbe.completeOutcome();
return {
completed,
status: document.querySelector('#today-sync-status').textContent,
pending: Object.keys(localStorage).filter(key =>
key.startsWith('stackchain.today-sync.v1.timmy.operation.')),
};
}""")
assert completion["completed"] is True
assert completion["pending"]
assert "sync pending" in completion["status"]
expect(page.locator("#mobile-first-task-receipt")).to_be_visible()
expect(coach).to_be_hidden()
expect(page.locator("#today-sync-status")).to_contain_text("Today saved to account")
remote_activation = page.evaluate("""async () => {
const response = await fetch('api/v1/today');
return (await response.json()).first_task_state;
}""")
assert remote_activation == "complete"
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
fresh_context = browser.new_context(
@@ -100,7 +143,12 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
fresh_page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
fresh_page.locator("#submit-sign-in").click()
fresh_page.wait_for_url(origin + "/", wait_until="networkidle")
fresh_page.evaluate(
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
)
expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work")
fresh_page.wait_for_load_state("networkidle")
assert fresh_page.evaluate(
"localStorage.getItem('stackchain.first-task.v1:timmy')"
) == "complete"
@@ -128,6 +176,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
browser_errors: list[str] = []
failed_responses: list[str] = []
workspace_requests: list[str] = []
optional_workspace_requests: list[str] = []
launch_transfer_events: list[str] = []
live_requests: list[str] = []
@@ -171,6 +220,12 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
)
if "feature-work-core-" in request.url else None,
)
page.on(
"request",
lambda request: optional_workspace_requests.append(request.url)
if "feature-today-timer-" in request.url or "feature-planning-" in request.url
else None,
)
page.on(
"requestfinished",
lambda request: launch_transfer_events.append("core-finished")
@@ -184,6 +239,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
)
page.goto(origin + "/", wait_until="networkidle")
assert optional_workspace_requests == []
page.locator('input[name="device_label"]').fill("Home bootstrap release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
live_requests.clear()
@@ -194,9 +250,24 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
), launch_transfer_events
expect(page.locator("#my-work-status")).to_contain_text("2")
assert optional_workspace_requests == []
revisionless_live_requests = [url for url in live_requests if "?" not in url]
assert len(revisionless_live_requests) == 1, live_requests
initial_live_requests = len(live_requests)
page.locator("#work-settings-toggle").click()
tomorrow = page.locator("#plan-tomorrow")
expect(tomorrow).to_be_visible()
assert len(optional_workspace_requests) == 2, optional_workspace_requests
tomorrow_bounds = tomorrow.bounding_box()
assert tomorrow_bounds and tomorrow_bounds["height"] >= 44
tomorrow.click()
expect(page.locator("#plan-today-sheet")).to_be_visible()
expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow")
expect(page.locator("#save-and-start-today")).to_be_hidden()
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#cancel-plan-today").click()
page.evaluate(
"""
() => {
@@ -226,18 +297,6 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
page.locator("#work-settings-toggle").click()
tomorrow = page.locator("#plan-tomorrow")
expect(tomorrow).to_be_visible()
tomorrow_bounds = tomorrow.bounding_box()
assert tomorrow_bounds and tomorrow_bounds["height"] >= 44
tomorrow.click()
expect(page.locator("#plan-today-sheet")).to_be_visible()
expect(page.locator("#plan-today-title")).to_have_text("Plan Tomorrow")
expect(page.locator("#save-and-start-today")).to_be_hidden()
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.locator("#cancel-plan-today").click()
page.locator('[data-mobile-task="queues"]').click()
page.locator(".mobile-queue-all summary").click()
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
@@ -346,7 +405,10 @@ def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_pa
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})
context = browser.new_context(
viewport={"width": 390, "height": 844}, service_workers="block"
)
page = context.new_page()
page.goto(origin + "/", wait_until="networkidle")
def interrupt_once(route):
@@ -361,6 +423,7 @@ def test_release_artifact_recovers_a_transient_workspace_request_in_place(tmp_pa
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.locator('[data-mobile-task="find"]').click()
page.wait_for_timeout(1500)
resources = page.evaluate(
@@ -406,6 +469,10 @@ def test_release_artifact_keeps_mobile_delivery_recovery_single_flight(tmp_path:
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(
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
)
page.evaluate(
"""
@@ -508,6 +575,10 @@ def test_release_artifact_reviews_and_downloads_mobile_agenda_snapshot(
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(
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
)
page.locator('[data-mobile-task="queues"]').click()
page.locator('[data-mobile-queue="agenda"]').click()
@@ -114,6 +114,13 @@ def release_server(archive: Path, tmp_path: Path, gitea_url: str):
process.communicate()
def hydrate_workspace(page) -> None:
page.evaluate(
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
)
def indexed_issue_records(page: Page) -> list[dict]:
return page.evaluate(
"""async () => {
@@ -275,6 +282,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
assert fake.created_issues == []
page.reload(wait_until="domcontentloaded")
hydrate_workspace(page)
expect(page.locator('[data-mobile-task="new"]')).to_be_visible()
durable_after_reload = indexed_issue_records(page)
assert [(item["title"], item["body"]) for item in durable_after_reload] == [(TITLE, BODY)]
@@ -322,6 +330,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
page.wait_for_timeout(100)
assert durable_completion and all(item.get("status") == "sent" for item in durable_completion)
page.reload(wait_until="networkidle")
hydrate_workspace(page)
page.evaluate("window.dispatchEvent(new Event('online'))")
for _ in range(40):
completed_local = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))
@@ -12,7 +12,7 @@ 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
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
def test_release_artifact_finds_and_reopens_photo_only_reply_from_mobile_my_work(tmp_path: Path):
@@ -60,6 +60,7 @@ def test_release_artifact_finds_and_reopens_photo_only_reply_from_mobile_my_work
})"""
)
page.reload(wait_until="networkidle")
hydrate_workspace(page)
stored = page.evaluate(
"""() => new Promise((resolve,reject)=>{
const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);
+17 -2
View File
@@ -12,7 +12,7 @@ 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
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
def open_today_action(page, selector: str):
@@ -52,6 +52,7 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
hydrate_workspace(page)
try:
expect(page.locator("#my-work-status")).to_contain_text("2")
@@ -269,6 +270,7 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
localStorage.setItem(prefix + 'broken', '{not-json');
}""")
page.reload(wait_until="networkidle")
hydrate_workspace(page)
expect(page.locator("#my-work-status")).to_contain_text("2")
expect(page.locator("#today-sync-status")).to_have_text(
"Today queue recovered · discarded 1 unreadable device record."
@@ -276,7 +278,16 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
assert page.evaluate("""() => !Object.keys(localStorage).some(
key => key.startsWith('stackchain.today-sync.v1.timmy.operation.')
)""")
expect(page.locator("#today-break-status")).to_contain_text("On break · resume in")
try:
expect(page.locator("#today-break-status")).to_contain_text("On break · resume in")
except AssertionError as error:
raise AssertionError(page.evaluate("""() => ({
login: document.querySelector('#current-user')?.textContent,
timers: Object.fromEntries(Object.keys(localStorage)
.filter(key => key.startsWith('stackchain.today-timer.v1.'))
.map(key => [key, JSON.parse(localStorage.getItem(key))])),
status: document.querySelector('#today-break-status').outerHTML,
})""")) from error
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
page.locator("#close-issue-sheet").click()
if page.locator("#plan-today-sheet").is_visible():
@@ -377,6 +388,7 @@ def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(t
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
hydrate_workspace(page)
expect(page.locator("#my-work-status")).to_contain_text("2")
page.locator('[data-mobile-task="work"]').click()
@@ -446,6 +458,7 @@ def test_release_artifact_pauses_today_across_mobile_work_and_insights_detours(t
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'insights';
}""")
page.reload(wait_until="networkidle")
hydrate_workspace(page)
restored_pause = page.locator("#insights-sheet > [data-today-detour]")
expect(restored_pause).to_be_visible()
expect(restored_pause).to_contain_text("Today paused · Ship mobile capture")
@@ -512,6 +525,7 @@ def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
hydrate_workspace(page)
expect(page.locator("#my-work-status")).to_contain_text("2")
page.locator('[data-mobile-task="work"]').click()
@@ -562,6 +576,7 @@ def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_
}));
}""")
page.reload(wait_until="networkidle")
hydrate_workspace(page)
expect(page.locator("#my-work-status")).to_contain_text("2")
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
page.locator("#close-issue-sheet").click()
@@ -12,7 +12,7 @@ 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
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
@@ -50,6 +50,7 @@ def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
hydrate_workspace(page)
page.evaluate(
"""
@@ -12,7 +12,7 @@ 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
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
@@ -47,6 +47,7 @@ def test_release_artifact_renders_and_applies_mobile_today_wrap_up(
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
hydrate_workspace(page)
page.evaluate(
"""
@@ -13,7 +13,7 @@ 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
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
@@ -71,6 +71,7 @@ def test_release_artifact_resolves_cross_device_tomorrow_conflicts_on_mobile(
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
hydrate_workspace(page)
def stage_phone_plan():
page.evaluate(
@@ -12,7 +12,7 @@ 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
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, hydrate_workspace, release_server
def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path):
@@ -36,6 +36,7 @@ def test_release_artifact_reviews_wrap_up_commitments_on_a_phone(tmp_path: Path)
'issue:acme/mobile:41:', 'issue:acme/mobile:42:'
]))""")
page.reload(wait_until="networkidle")
hydrate_workspace(page)
dialog = page.locator("#today-handoff-dialog")
expect(dialog).to_be_visible()
+7 -1
View File
@@ -6,6 +6,8 @@ from src import main
@pytest.mark.anyio
async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
async def user():
return {"id": 1, "login": "timmy"}
@@ -15,13 +17,17 @@ async def test_live_gitea_api_responses_cannot_be_stored_by_shared_caches(monkey
async def empty_events(user_data=None):
return []
async def empty_live_snapshot(**_revisions):
return main.JSONResponse({})
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "repos", empty_collection)
monkeypatch.setattr(main, "issues", empty_collection)
monkeypatch.setattr(main, "pull_requests", empty_collection)
monkeypatch.setattr(main, "activity_events", empty_events)
monkeypatch.setattr(main, "_live_snapshot_response", empty_live_snapshot)
transport = httpx.ASGITransport(app=main.app)
transport = httpx.ASGITransport(app=main.app, client=("127.0.0.1", 1234))
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
for path in ("/api/v1/context", "/api/v1/events", "/api/v1/live"):
response = await client.get(path)
+29
View File
@@ -117,6 +117,32 @@ const poller = createContextPoller({{
}
def test_context_poller_can_force_a_full_identity_refresh_after_adoption():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
const requested = [];
const seed = {{
context:{{user:{{login:'timmy'}}}}, events:[], notifications:[],
revisions:{{context:'0123456789abcdef.1',events:'fedcba9876543210.2'}},
}};
const poller = createContextPoller({{
fetchContext: revisions => {{
requested.push({{...revisions}});
return Promise.resolve({{...seed, freshness:{{sections:{{context:{{degraded:false}}}}}}}});
}},
onSnapshot: () => {{}}, onError: error => {{ throw error; }},
setTimer: () => 1, clearTimer: () => {{}},
}});
poller.adopt(seed);
(async () => {{
await poller.refresh({{force:true, full:true}});
process.stdout.write(JSON.stringify({{requested}}));
}})();
"""
assert run_node(script) == {"requested": [{}]}
def test_context_poller_adopts_progressive_snapshot_before_revision_conditional_refresh():
script = f"""
const createContextPoller = require({json.dumps(str(POLLER))});
@@ -200,6 +226,9 @@ def test_dashboard_adopts_progressive_snapshot_or_falls_back_to_immediate_load()
assert "let adoptedProgressiveSnapshot = contextPoller.adopt(progressiveWorkHandoff?.liveSnapshot);" in source
assert "contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise)" in source
assert "if (!adoptedProgressiveSnapshot) await load();" in source
assert "contextPoller.refresh({ force:true, full:true })" in source
assert "fetchReviewJson('api/v1/background-identity')" in source
assert "initialAccountRecovery = timerView.restore(todaySync.flush());" in source
def test_context_poller_uses_failed_section_retry_before_healthy_freshness_deadline():
+14 -1
View File
@@ -33,6 +33,15 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
assert first.runtime_name.startswith("runtime-")
assert first.runtime_name.endswith(".js")
assert len(gzip.compress(first.runtime_bytes, mtime=0)) <= 100 * 1024
initial_mobile_javascript = (
len(first.runtime_gzip_bytes)
+ len(first.feature_bundles["work-core"].runtime_gzip_bytes)
)
assert initial_mobile_javascript <= 60 * 1024
assert (
len(first.feature_bundles["today-timer"].runtime_gzip_bytes)
+ len(first.feature_bundles["planning"].runtime_gzip_bytes)
) > initial_mobile_javascript
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
@@ -100,9 +109,13 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
)
optional_block = optional_block.split("];", 1)[0]
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" not in shell_block
demand_loaded = {"today-timer", "planning"}
for name, bundle in first.feature_bundles.items():
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
assert f"BASE + '{bundle.runtime_name}'" in optional_block
if name in demand_loaded:
assert f"BASE + '{bundle.runtime_name}'" not in optional_block
else:
assert f"BASE + '{bundle.runtime_name}'" in optional_block
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
+141
View File
@@ -0,0 +1,141 @@
import json
import subprocess
from pathlib import Path
RECENT_WORK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-recent-work.js"
INDEX = Path(__file__).resolve().parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).resolve().parents[1] / "frontend" / "dashboard.css"
def run_node(script: str) -> dict:
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_recent_work_is_account_scoped_deduplicated_and_bounded():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key, value) => values.set(key, value),
removeItem:key => values.delete(key),
}};
let login = ' Alice ';
const recent = createRecentWork({{storage, getLogin:() => login, limit:5}});
for (let number=1; number<=6; number += 1) {{
recent.record({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
}}
recent.record({{kind:'issue', repository:'stackchain/dashboard', number:3, title:'Issue 3 updated'}});
const alice = recent.items();
login = 'bob';
recent.record({{kind:'pull', repository:'stackchain/api', number:9, title:'Ship API'}});
const bob = recent.items();
login = '';
const anonymousRecord = recent.record({{kind:'issue', repository:'stackchain/dashboard', number:99, title:'Private'}});
const anonymous = recent.items();
process.stdout.write(JSON.stringify({{alice,bob,anonymousRecord,anonymous,keys:Array.from(values.keys()).sort()}}));
"""
payload = run_node(script)
assert [item["number"] for item in payload["alice"]] == [3, 6, 5, 4, 2]
assert payload["alice"][0] == {
"kind": "issue",
"repository": "stackchain/dashboard",
"number": 3,
"title": "Issue 3 updated",
"route": "#/my-work/issue/stackchain/dashboard/3",
}
assert payload["bob"] == [
{
"kind": "pull",
"repository": "stackchain/api",
"number": 9,
"title": "Ship API",
"route": "#/my-work/pull/stackchain/api/9",
}
]
assert payload["anonymousRecord"] is False
assert payload["anonymous"] == []
assert payload["keys"] == [
"stackchain.mobile-recent-work.v1.alice",
"stackchain.mobile-recent-work.v1.bob",
]
def test_recent_work_renders_safe_rows_and_opens_the_selected_route():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
const values = new Map([
['stackchain.mobile-recent-work.v1.alice', JSON.stringify([
{{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Fix mobile queue',route:'#/wrong'}},
{{kind:'update',number:42,title:'Review release status'}},
{{kind:'pull',repository:'bad/repo/extra',number:1,title:'Unsafe'}},
{{kind:'issue',repository:'stackchain/dashboard',number:0,title:'Invalid'}},
])],
]);
function node(tag) {{
return {{tag,children:[],attributes:{{}},listeners:{{}},hidden:false,textContent:'',
appendChild(child){{this.children.push(child);return child;}},
replaceChildren(...children){{this.children=children;}},
setAttribute(name,value){{this.attributes[name]=String(value);}},
addEventListener(name,callback){{this.listeners[name]=callback;}},
click(){{this.listeners.click?.();}},
}};
}}
const list=node('div'); const section=node('section'); const opened=[];
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice', document:{{createElement:node}}, list, section,
openRoute:route=>opened.push(route),
}});
const rendered=recent.render();
list.children[1].click();
process.stdout.write(JSON.stringify({{
rendered,hidden:section.hidden,rows:list.children.map(button=>({{
label:button.attributes['aria-label'],route:button.attributes['data-recent-work-route'],
primary:button.children[0].children[0].textContent,
secondary:button.children[0].children[1].textContent,
}})),opened,
}}));
"""
payload = run_node(script)
assert payload == {
"rendered": 2,
"hidden": False,
"rows": [
{
"label": "Open Fix mobile queue, issue stackchain/dashboard #7",
"route": "#/my-work/issue/stackchain/dashboard/7",
"primary": "Fix mobile queue",
"secondary": "Issue · stackchain/dashboard #7",
},
{
"label": "Open Review release status, update #42",
"route": "#/my-work/update/42",
"primary": "Review release status",
"secondary": "Update · #42",
},
],
"opened": ["#/my-work/update/42"],
}
def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
html = INDEX.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="mobile-recent-work"' in html
assert 'id="mobile-recent-work-list"' in html
assert '<script src="static/mobile-recent-work.js"></script>' in html
assert "createMobileRecentWork({" in dashboard
assert "mobileRecentWork.record(item)" in dashboard
assert "mobileRecentWork.render()" in dashboard
assert "workRoute.sync()" in dashboard
assert "[data-recent-work-route]" in css
assert "min-height:56px" in css
+91 -24
View File
@@ -149,42 +149,109 @@ const fetchJson = async (url, init={{}}) => {{
]
def test_mobile_queue_priority_serializes_rapid_edits_and_publishes_latest_order():
def test_mobile_queue_priority_coalesces_rapid_edits_and_publishes_latest_order():
script = f"""
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
const values = new Map(); const pending=[]; const payloads=[];
const fetchJson = async (_url, init) => new Promise(resolve => {{
const payload=JSON.parse(init.body); payloads.push(payload); pending.push(() => resolve({{revision:payload.revision+1,order:payload.order}}));
}});
const values = new Map(); const payloads=[]; const timers=[];
const setTimer = (callback, delay) => {{ const timer={{callback,delay,cancelled:false}}; timers.push(timer); return timer; }};
const clearTimer = timer => {{ if (timer) timer.cancelled=true; }};
const fetchJson = async (_url, init) => {{
const payload=JSON.parse(init.body); payloads.push(payload);
return {{revision:payload.revision+1,order:payload.order}};
}};
(async () => {{
let priority;
priority=createPriority({{
const priority=createPriority({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
getLogin:()=>'alice', fetchJson, onChange:()=>{{ void priority.sync(); }},
getLogin:()=>'alice', fetchJson, setTimeout:setTimer, clearTimeout:clearTimer, debounceMs:150,
}});
priority.move('following', -1);
priority.move('following', -1);
for (let index=0; index<4; index += 1) priority.move('following', -1);
const before=payloads.length;
const active=timers.filter(timer=>!timer.cancelled);
active[0].callback();
await new Promise(resolve => setImmediate(resolve));
const firstPending=pending.length;
pending.shift()();
await new Promise(resolve => setImmediate(resolve));
const secondPending=pending.length;
pending.shift()();
await new Promise(resolve => setImmediate(resolve));
process.stdout.write(JSON.stringify({{firstPending,secondPending,payloads,state:priority.state()}}));
process.stdout.write(JSON.stringify({{before,active:active.map(timer=>timer.delay),payloads,state:priority.state()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["firstPending"] == 1
assert payload["secondPending"] == 1
assert len(payload["payloads"]) == 2
assert payload["before"] == 0
assert payload["active"] == [150]
assert len(payload["payloads"]) == 1
assert payload["payloads"][0]["revision"] == 0
assert payload["payloads"][1]["revision"] == 1
assert payload["payloads"][1]["order"] == payload["state"]["order"]
assert payload["state"]["revision"] == 2
assert payload["payloads"][0]["order"] == payload["state"]["order"]
assert payload["state"]["revision"] == 1
assert payload["state"]["status"] == "ready"
def test_mobile_queue_priority_retries_transient_failure_without_another_edit():
script = f"""
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
const values = new Map(); const timers=[]; let calls=0;
const setTimer = (callback, delay) => {{ const timer={{callback,delay,cancelled:false}}; timers.push(timer); return timer; }};
const clearTimer = timer => {{ if (timer) timer.cancelled=true; }};
const fetchJson = async (_url, init) => {{
calls += 1;
if (calls === 1) {{ const error=new Error('temporary'); error.status=503; throw error; }}
const payload=JSON.parse(init.body);
return {{revision:payload.revision+1,order:payload.order}};
}};
(async () => {{
const priority=createPriority({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
getLogin:()=>'alice', fetchJson, setTimeout:setTimer, clearTimeout:clearTimer,
debounceMs:150, retryBaseMs:1000, retryMaxMs:30000,
}});
priority.move('following', -1);
timers.find(timer=>!timer.cancelled).callback();
await new Promise(resolve => setImmediate(resolve));
const afterFailure=priority.state();
const retry=timers.filter(timer=>!timer.cancelled).at(-1);
retry.callback();
await new Promise(resolve => setImmediate(resolve));
process.stdout.write(JSON.stringify({{calls,afterFailure,retryDelay:retry.delay,settled:priority.state()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["afterFailure"]["pending"] is True
assert payload["afterFailure"]["status"] == "pending"
assert payload["retryDelay"] == 1000
assert payload["calls"] == 2
assert payload["settled"]["pending"] is False
assert payload["settled"]["status"] == "ready"
def test_mobile_queue_priority_refreshes_clean_device_when_foregrounded():
script = f"""
const createPriority = require({json.dumps(str(QUEUE_PRIORITY))});
const values = new Map(); const windowListeners={{}}; const documentListeners={{}};
const remoteOrder=['attention','following','today','update','agenda','authored','filed','later','draft'];
let calls=0;
const documentRef={{hidden:false,addEventListener:(name, callback)=>documentListeners[name]=callback}};
const windowRef={{addEventListener:(name, callback)=>windowListeners[name]=callback}};
const priority=createPriority({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}},
getLogin:()=>'alice', fetchJson:async () => {{ calls += 1; return {{revision:4,order:remoteOrder.slice()}}; }},
}});
(async () => {{
priority.startLifecycle({{window:windowRef,document:documentRef}});
documentListeners.visibilitychange();
await new Promise(resolve => setImmediate(resolve));
process.stdout.write(JSON.stringify({{calls,state:priority.state(),listeners:[...Object.keys(windowListeners),...Object.keys(documentListeners)]}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert set(payload["listeners"]) == {"online", "visibilitychange"}
assert payload["calls"] == 1
assert payload["state"]["revision"] == 4
assert payload["state"]["order"][1] == "following"
assert payload["state"]["status"] == "ready"
@@ -535,7 +602,7 @@ async def test_mobile_queue_priority_wires_cross_device_hydration_and_explicit_c
assert 'id="use-remote-mobile-queue-priority"' in html
assert "fetchJson: fetchReviewJson" in html
assert "void mobileQueuePriority.load();" in html
assert "void mobileQueuePriority.sync();" in html
assert "mobileQueuePriority.startLifecycle({window, document});" in html
assert "keepLocalButton: qs('#keep-local-mobile-queue-priority')" in html
assert "useRemoteButton: qs('#use-remote-mobile-queue-priority')" in html
assert "error.payload = payload;" in html
+1
View File
@@ -1527,6 +1527,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-first-task.js",
"/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-recent-work.js",
"/dashboard/static/mobile-queue-priority.js",
"/dashboard/static/mobile-queue-launcher.js",
"/dashboard/static/mobile-delivery-recovery.js",
+73 -10
View File
@@ -19,7 +19,7 @@ const loadWorkspace = require({json.dumps(str(BOOTSTRAP))});
return json.loads(completed.stdout)
def test_workspace_bootstrap_loads_content_addressed_feature_before_startup():
def test_workspace_bootstrap_demand_loads_optional_features_after_work_core():
result = run_bootstrap("""
const status={textContent:''};
const document={
@@ -33,11 +33,14 @@ const document={
};
const requested=[];
const createLoader=options=>({load:async name=>{requested.push(name + ':' + options.urls[name]);}});
await loadWorkspace({document,createLoader});
console.log(JSON.stringify({requested,status:status.textContent}));
const lifecycle=await loadWorkspace({document,createLoader});
const before=requested.slice();
await lifecycle.hydrateWorkspace?.();
console.log(JSON.stringify({before,after:requested,status:status.textContent}));
""")
assert result == {
"requested": [
"before": ["work-core:feature-work-core-123.js"],
"after": [
"work-core:feature-work-core-123.js",
"today-timer:feature-workspace-abc.js",
"planning:feature-planning-def.js",
@@ -46,7 +49,65 @@ console.log(JSON.stringify({requested,status:status.textContent}));
}
def test_workspace_bootstrap_returns_after_work_core_while_optional_features_hydrate():
def test_workspace_bootstrap_hydrates_once_and_replays_dependent_mobile_action():
result = run_bootstrap("""
const listeners={}; const requested=[];
const document={
querySelector(selector) {
const match=selector.match(/stackchain-feature-([^\"]+)/);
return match ? {content:'feature-' + match[1] + '.js'} : null;
},
addEventListener(name,callback,capture){listeners[name]={callback,capture};},
removeEventListener(name,callback,capture){
if(listeners[name]?.callback===callback && listeners[name]?.capture===capture) delete listeners[name];
},
};
const createLoader=()=>({load:async name=>{
requested.push(name);
if(name==='today-timer') setImmediate(()=>{
actionReady=true;
lifecycle.markWorkspaceReady?.();
});
}});
let lifecycle=await loadWorkspace({document,createLoader});
let prevented=0,stopped=0,replayed=0,actionReady=false,replayedReady=false;
const target={
closest(selector){return selector.includes(':not([data-mobile-task="work"])') ? null : this;},
click(){replayed++; replayedReady=actionReady;},
};
await listeners.click?.callback({target,preventDefault(){prevented++;},stopImmediatePropagation(){stopped++;}});
console.log(JSON.stringify({requested,prevented,stopped,replayed,replayedReady,listening:Boolean(listeners.click)}));
""")
assert result == {
"requested": ["work-core", "today-timer", "planning"],
"prevented": 1,
"stopped": 1,
"replayed": 1,
"replayedReady": True,
"listening": False,
}
def test_workspace_bootstrap_hydrates_immediately_for_deep_link():
result = run_bootstrap("""
const requested=[];
const document={
querySelector(selector) {
const match=selector.match(/stackchain-feature-([^\"]+)/);
return match ? {content:'feature-' + match[1] + '.js'} : null;
},
addEventListener(){},
};
const window={location:{hash:'#/my-work/today'},addEventListener(){},removeEventListener(){}};
const createLoader=()=>({load:async name=>{requested.push(name);}});
const lifecycle=await loadWorkspace({document,window,createLoader});
await lifecycle.deepLinkReady;
console.log(JSON.stringify({requested}));
""")
assert result == {"requested": ["work-core", "today-timer", "planning"]}
def test_workspace_bootstrap_returns_after_work_core_before_optional_features_hydrate():
result = run_bootstrap("""
const document={querySelector(selector) {
const match=selector.match(/stackchain-feature-([^\"]+)/);
@@ -60,12 +121,13 @@ const createLoader=()=>({load:name=>{
}});
const lifecycle=await loadWorkspace({document,createLoader});
const returned=requested.slice();
const hydration=lifecycle.hydrateWorkspace();
releases['today-timer'](); releases.planning();
await lifecycle.optionalReady;
await hydration;
console.log(JSON.stringify({returned,settled:requested}));
""")
assert result == {
"returned": ["work-core", "today-timer", "planning"],
"returned": ["work-core"],
"settled": ["work-core", "today-timer", "planning"],
}
@@ -81,7 +143,8 @@ const document={querySelector(selector) {
}};
const createLoader=()=>({load:async()=>{attempts++; if (attempts === 1) throw new Error('brief outage');}});
const schedule=callback=>{callback();};
await loadWorkspace({document,createLoader,schedule});
const lifecycle=await loadWorkspace({document,createLoader,schedule});
await lifecycle.hydrateWorkspace();
console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden}));
""")
assert result == {"attempts": 4, "status": "", "retryHidden": True}
@@ -107,7 +170,7 @@ await Promise.all([first,second,loading]);
console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
""")
assert result == {
"attempts": 5,
"attempts": 3,
"reloads": 0,
"offered": {
"hidden": False,
@@ -151,7 +214,7 @@ await loading;
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
""")
assert result == {
"attempts": 5,
"attempts": 3,
"waiting": True,
"reloads": 0,
"status": "",