Merge pull request 'Hydrate mobile My Work before optional tools' (#1402) from timmy/1401-progressive-my-work-hydration into main
This commit is contained in:
commit
024087d051
|
|
@ -1,5 +1,7 @@
|
||||||
(async function(){
|
(async function(){
|
||||||
const workspaceLifecycle = await loadWorkspace({ document, window });
|
const workspaceLifecycle = await (window.stackchainWorkspaceLifecycle || loadWorkspace({ document, window }));
|
||||||
|
await workspaceLifecycle.optionalReady;
|
||||||
|
window.stackchainProgressiveMyWork?.stop();
|
||||||
const qs = (s, el=document) => el.querySelector(s);
|
const qs = (s, el=document) => el.querySelector(s);
|
||||||
const announceWork = message => qs('#my-work-action-status').textContent = message;
|
const announceWork = message => qs('#my-work-action-status').textContent = message;
|
||||||
const fmt = (d) => new Date(d).toLocaleString();
|
const fmt = (d) => new Date(d).toLocaleString();
|
||||||
|
|
|
||||||
|
|
@ -2284,6 +2284,7 @@
|
||||||
<script src="static/offline-work.js"></script>
|
<script src="static/offline-work.js"></script>
|
||||||
<script src="static/offline-today.js"></script>
|
<script src="static/offline-today.js"></script>
|
||||||
<script src="static/my-work.js"></script>
|
<script src="static/my-work.js"></script>
|
||||||
|
<script src="static/progressive-my-work.js"></script>
|
||||||
<script src="static/agenda-replan.js"></script>
|
<script src="static/agenda-replan.js"></script>
|
||||||
<script src="static/agenda-calendar.js"></script>
|
<script src="static/agenda-calendar.js"></script>
|
||||||
<script src="static/protect-today.js"></script>
|
<script src="static/protect-today.js"></script>
|
||||||
|
|
|
||||||
81
frontend/progressive-my-work.js
Normal file
81
frontend/progressive-my-work.js
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
function createProgressiveMyWork({ document, fetchSnapshot }) {
|
||||||
|
const list = document.querySelector('#my-work-list');
|
||||||
|
const status = document.querySelector('#my-work-status');
|
||||||
|
const filters = Array.from(document.querySelectorAll('[data-work-filter]'));
|
||||||
|
const listeners = [];
|
||||||
|
let items = [];
|
||||||
|
let active = 'all';
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
const escapeHtml = value => String(value || '').replace(/[&<>"']/g, character => ({
|
||||||
|
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
||||||
|
})[character]);
|
||||||
|
const safeUrl = value => {
|
||||||
|
try {
|
||||||
|
const url = new URL(String(value || ''), globalThis.location?.href || 'https://invalid.example/');
|
||||||
|
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
|
||||||
|
} catch (_error) { return ''; }
|
||||||
|
};
|
||||||
|
const visibleItems = () => active === 'all' ? items : items.filter(item =>
|
||||||
|
active === 'review' ? item.is_review :
|
||||||
|
active === 'update' ? item.has_update :
|
||||||
|
active === 'attention' ? item.needs_attention : item.kind === active
|
||||||
|
);
|
||||||
|
const render = () => {
|
||||||
|
if (stopped || !list) return;
|
||||||
|
const visible = visibleItems();
|
||||||
|
list.innerHTML = visible.length ? visible.map(item => {
|
||||||
|
const href = safeUrl(item.url);
|
||||||
|
const title = escapeHtml(item.title || item.key || 'Untitled work');
|
||||||
|
const context = escapeHtml(item.key || '');
|
||||||
|
const reason = escapeHtml(item.reason || 'Assigned to you');
|
||||||
|
return '<article class="my-work-card progressive-my-work-card">' +
|
||||||
|
(href ? '<a class="my-work-card-main" href="' + escapeHtml(href) + '">' : '<div class="my-work-card-main">') +
|
||||||
|
'<strong>' + title + '</strong><span class="small">' + context + ' · ' + reason + '</span>' +
|
||||||
|
(href ? '</a>' : '</div>') + '</article>';
|
||||||
|
}).join('') : '<div class="muted">No work in this queue.</div>';
|
||||||
|
filters.forEach(button => button.setAttribute('aria-pressed', String(button.dataset.workFilter === active)));
|
||||||
|
};
|
||||||
|
filters.forEach(button => {
|
||||||
|
const listener = () => { active = button.dataset.workFilter || 'all'; render(); };
|
||||||
|
button.addEventListener('click', listener);
|
||||||
|
listeners.push([button, listener]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
async start() {
|
||||||
|
if (status) status.textContent = 'Loading assigned work…';
|
||||||
|
try {
|
||||||
|
const snapshot = await fetchSnapshot();
|
||||||
|
if (stopped) return false;
|
||||||
|
const context = snapshot?.context || snapshot || {};
|
||||||
|
items = buildMyWork({ ...context, notifications:snapshot?.notifications || context.notifications || [] });
|
||||||
|
render();
|
||||||
|
const assigned = items.filter(item => item.is_assigned).length;
|
||||||
|
if (status) status.textContent = assigned + ' assigned work item' + (assigned === 1 ? '' : 's') + ' ready.';
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
if (status && !stopped) status.textContent = 'Assigned work is reconnecting…';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
listeners.forEach(([button, listener]) => button.removeEventListener?.('click', listener));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||||
|
window.stackchainProgressiveMyWork = createProgressiveMyWork({
|
||||||
|
document,
|
||||||
|
fetchSnapshot: async () => {
|
||||||
|
const response = await fetch('api/v1/live', { headers:{Accept:'application/json'} });
|
||||||
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
void window.stackchainProgressiveMyWork.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createProgressiveMyWork;
|
||||||
|
|
@ -186,6 +186,7 @@ const SHELL = [
|
||||||
BASE + 'static/offline-work.js',
|
BASE + 'static/offline-work.js',
|
||||||
BASE + 'static/offline-today.js',
|
BASE + 'static/offline-today.js',
|
||||||
BASE + 'static/my-work.js',
|
BASE + 'static/my-work.js',
|
||||||
|
BASE + 'static/progressive-my-work.js',
|
||||||
BASE + 'static/agenda-replan.js',
|
BASE + 'static/agenda-replan.js',
|
||||||
BASE + 'static/agenda-calendar.js',
|
BASE + 'static/agenda-calendar.js',
|
||||||
BASE + 'static/protect-today.js',
|
BASE + 'static/protect-today.js',
|
||||||
|
|
|
||||||
|
|
@ -2,76 +2,110 @@ async function loadWorkspace({
|
||||||
document,
|
document,
|
||||||
window = null,
|
window = null,
|
||||||
createLoader = createFeatureLoader,
|
createLoader = createFeatureLoader,
|
||||||
schedule = callback => setTimeout(callback,750),
|
schedule = callback => setTimeout(callback, 750),
|
||||||
}) {
|
}) {
|
||||||
let cameOnline = false;
|
let cameOnline = false;
|
||||||
let replayed = false;
|
let replayed = false;
|
||||||
let retryInFlight = null;
|
|
||||||
const captureOnline = () => { cameOnline = true; };
|
|
||||||
window?.addEventListener('online', captureOnline);
|
|
||||||
const status = document.querySelector('#my-work-action-status');
|
const status = document.querySelector('#my-work-action-status');
|
||||||
const retryButton = document.querySelector('#retry-workspace');
|
const retryButton = document.querySelector('#retry-workspace');
|
||||||
const urls = Object.fromEntries(['today-timer', 'planning'].map(name => [name,
|
const names = ['work-core', 'today-timer', 'planning'];
|
||||||
|
const urls = Object.fromEntries(names.map(name => [name,
|
||||||
document.querySelector(`meta[name="stackchain-feature-${name}"]`)?.content || ''
|
document.querySelector(`meta[name="stackchain-feature-${name}"]`)?.content || ''
|
||||||
]));
|
]));
|
||||||
const originalUrls = {...urls};
|
const originalUrls = {...urls};
|
||||||
const loader = createLoader({document, urls});
|
const loader = createLoader({document, urls});
|
||||||
|
const attempts = Object.fromEntries(names.map(name => [name, 0]));
|
||||||
|
const failed = new Set();
|
||||||
|
const recoveries = new Map();
|
||||||
|
let retryInFlight = null;
|
||||||
|
|
||||||
let attempts = 0;
|
const loadFeature = name => {
|
||||||
const load = () => {
|
attempts[name] += 1;
|
||||||
if (attempts++) Object.keys(urls).forEach(name => {
|
urls[name] = originalUrls[name] + (attempts[name] > 1 ? '?retry=' + attempts[name] : '');
|
||||||
urls[name] = originalUrls[name] + '?retry=' + attempts;
|
return loader.load(name);
|
||||||
});
|
|
||||||
return Promise.all(Object.keys(urls).map(name => loader.load(name)));
|
|
||||||
};
|
};
|
||||||
const waitForRecovery = () => new Promise(resolve => {
|
const retryOnce = async name => {
|
||||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
try { return await loadFeature(name); }
|
||||||
if (retryButton) {
|
catch (_error) {
|
||||||
retryButton.hidden = retryButton.disabled = false;
|
await new Promise(resolve => schedule(resolve));
|
||||||
|
return loadFeature(name);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
const recover = () => {
|
const showRecovery = () => {
|
||||||
if (retryInFlight) return retryInFlight;
|
if (status) status.textContent = failed.has('work-core') ?
|
||||||
if (retryButton) retryButton.disabled = true;
|
'Workspace unavailable. Reconnect or retry.' :
|
||||||
retryInFlight = load().then(() => {
|
'Today or planning tools unavailable. My Work is ready; reconnect or retry.';
|
||||||
window?.removeEventListener('online', recover);
|
if (retryButton) retryButton.hidden = retryButton.disabled = false;
|
||||||
resolve();
|
};
|
||||||
}).catch(() => {
|
const hideRecovery = () => {
|
||||||
if (status) status.textContent = 'Workspace unavailable. Reconnect or retry.';
|
if (failed.size) return;
|
||||||
if (retryButton) retryButton.disabled = false;
|
if (retryButton) retryButton.hidden = true;
|
||||||
}).finally(() => {
|
if (status) status.textContent = '';
|
||||||
retryInFlight = null;
|
};
|
||||||
});
|
const retryFailed = () => {
|
||||||
return retryInFlight;
|
if (retryInFlight) return retryInFlight;
|
||||||
};
|
if (retryButton) retryButton.disabled = true;
|
||||||
|
const pending = Array.from(failed);
|
||||||
window?.removeEventListener('online', captureOnline);
|
retryInFlight = Promise.all(pending.map(async name => {
|
||||||
window?.addEventListener('online', recover);
|
try {
|
||||||
retryButton?.addEventListener('click', recover);
|
await loadFeature(name);
|
||||||
});
|
failed.delete(name);
|
||||||
|
recoveries.get(name)?.resolve(true);
|
||||||
|
recoveries.delete(name);
|
||||||
|
} catch (_error) {}
|
||||||
|
})).then(() => {
|
||||||
|
if (failed.size) showRecovery();
|
||||||
|
else hideRecovery();
|
||||||
|
}).finally(() => { retryInFlight = null; });
|
||||||
|
return retryInFlight;
|
||||||
|
};
|
||||||
|
retryButton?.addEventListener?.('click', retryFailed);
|
||||||
|
const handleOnline = () => { cameOnline = true; void retryFailed(); };
|
||||||
|
window?.addEventListener('online', handleOnline);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await load();
|
await retryOnce('work-core');
|
||||||
} catch {
|
} catch (_error) {
|
||||||
await new Promise(resolve => schedule(resolve));
|
failed.add('work-core');
|
||||||
try {
|
showRecovery();
|
||||||
await load();
|
await new Promise(resolve => recoveries.set('work-core', {resolve}));
|
||||||
} catch {
|
|
||||||
await waitForRecovery();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
window?.removeEventListener('online', captureOnline);
|
|
||||||
if (retryButton) retryButton.hidden = true;
|
|
||||||
if (status) status.textContent = '';
|
|
||||||
return {
|
return {
|
||||||
|
optionalReady,
|
||||||
|
retryFeature(name) {
|
||||||
|
if (!failed.has(name)) return Promise.resolve(true);
|
||||||
|
return retryFailed().then(() => !failed.has(name));
|
||||||
|
},
|
||||||
replayOnline(callback) {
|
replayOnline(callback) {
|
||||||
if (replayed) return;
|
if (replayed) return;
|
||||||
replayed = true;
|
replayed = true;
|
||||||
window?.removeEventListener('online', captureOnline);
|
window?.removeEventListener('online', handleOnline);
|
||||||
if (cameOnline) callback();
|
if (cameOnline) callback();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && typeof document !== 'undefined' &&
|
||||||
|
typeof createFeatureLoader === 'function') {
|
||||||
|
window.stackchainWorkspaceLifecycle = loadWorkspace({document, window});
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
|
if (typeof module !== 'undefined' && module.exports) module.exports = loadWorkspace;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ COMMONJS_BROWSER_BRANCH = re.compile(
|
||||||
)
|
)
|
||||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||||
FEATURE_SOURCES = {
|
FEATURE_SOURCES = {
|
||||||
|
"work-core": ("static/my-work.js", "static/progressive-my-work.js"),
|
||||||
"comment-actions": ("static/comment-actions.js",),
|
"comment-actions": ("static/comment-actions.js",),
|
||||||
"issue-capture": (
|
"issue-capture": (
|
||||||
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/create-pull-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
"static/voice-transcript-store.js", "static/voice-issue-capture.js", "static/create-issue-sheet.js", "static/create-pull-sheet.js", "static/mobile-create-issue-nav.js", "static/update-follow-up.js", "static/shared-image-capture.js",
|
||||||
|
|
@ -40,13 +41,13 @@ FEATURE_SOURCES = {
|
||||||
),
|
),
|
||||||
"today-timer": (
|
"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/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/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-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-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-captures.js", "static/unfiled-draft-sync.js",
|
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/photo-draft-inbox.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/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",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||||
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",
|
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||||
"static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
"static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||||
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/mobile-update-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/mobile-update-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js", "static/dashboard.js",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
CACHE_DECLARATION = re.compile(
|
CACHE_DECLARATION = re.compile(
|
||||||
|
|
@ -122,7 +123,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||||
)
|
)
|
||||||
workspace_preload = (
|
workspace_preload = (
|
||||||
f'<link rel="preload" as="script" '
|
f'<link rel="preload" as="script" '
|
||||||
f'href="{feature_bundles["today-timer"].runtime_name}">'
|
f'href="{feature_bundles["work-core"].runtime_name}">'
|
||||||
)
|
)
|
||||||
dashboard_html = dashboard_html.replace(
|
dashboard_html = dashboard_html.replace(
|
||||||
"</head>", feature_metadata + "\n" + workspace_preload + "\n</head>"
|
"</head>", feature_metadata + "\n" + workspace_preload + "\n</head>"
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||||
workspace_requests.append(request.url),
|
workspace_requests.append(request.url),
|
||||||
launch_transfer_events.append("workspace-requested"),
|
launch_transfer_events.append("workspace-requested"),
|
||||||
)
|
)
|
||||||
if "feature-today-timer-" in request.url else None,
|
if "feature-work-core-" in request.url else None,
|
||||||
)
|
)
|
||||||
page.on(
|
page.on(
|
||||||
"requestfinished",
|
"requestfinished",
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
|
|
||||||
assert set(first.feature_bundles) == {
|
assert set(first.feature_bundles) == {
|
||||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
|
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "sign-out", "device-setup",
|
||||||
"today-timer", "security-center", "planning",
|
"work-core", "today-timer", "security-center", "planning",
|
||||||
}
|
}
|
||||||
assert first.dashboard_html.count("<script src=") == 1
|
assert first.dashboard_html.count("<script src=") == 1
|
||||||
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
|
||||||
|
|
@ -95,7 +95,6 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source
|
assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source
|
||||||
assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html
|
assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html
|
||||||
assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source
|
assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source
|
||||||
|
|
||||||
shell_block, optional_block = first.service_worker_source.split(
|
shell_block, optional_block = first.service_worker_source.split(
|
||||||
"const OPTIONAL_FEATURES = [", 1
|
"const OPTIONAL_FEATURES = [", 1
|
||||||
)
|
)
|
||||||
|
|
@ -127,14 +126,29 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
assert security_changed.feature_bundles["security-center"].runtime_name != security_center.runtime_name
|
assert security_changed.feature_bundles["security-center"].runtime_name != security_center.runtime_name
|
||||||
|
|
||||||
|
|
||||||
|
def test_my_work_has_a_small_blocking_bundle_before_optional_workspace_hydration():
|
||||||
|
build = build_frontend(FRONTEND)
|
||||||
|
work_core = build.feature_bundles["work-core"]
|
||||||
|
today = build.feature_bundles["today-timer"]
|
||||||
|
|
||||||
|
assert b"function buildMyWork" in work_core.runtime_bytes
|
||||||
|
assert b"function createProgressiveMyWork" in work_core.runtime_bytes
|
||||||
|
assert b"function buildMyWork" not in today.runtime_bytes
|
||||||
|
assert b"loadWorkspace({document,window})" in build.runtime_bytes
|
||||||
|
assert b"const workspaceLifecycle" not in build.runtime_bytes
|
||||||
|
assert b"const workspaceLifecycle" in today.runtime_bytes
|
||||||
|
assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [work_core.runtime_name]
|
||||||
|
assert len(build.runtime_gzip_bytes) + len(work_core.runtime_gzip_bytes) <= 150 * 1024
|
||||||
|
|
||||||
|
|
||||||
def test_mandatory_workspace_fetch_is_preloaded_without_blocking_launch():
|
def test_mandatory_workspace_fetch_is_preloaded_without_blocking_launch():
|
||||||
build = build_frontend(FRONTEND)
|
build = build_frontend(FRONTEND)
|
||||||
workspace = build.feature_bundles["today-timer"]
|
workspace = build.feature_bundles["work-core"]
|
||||||
|
|
||||||
assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [workspace.runtime_name]
|
assert SCRIPT_PRELOAD.findall(build.dashboard_html) == [workspace.runtime_name]
|
||||||
assert build.dashboard_html.count(f'<script src="{workspace.runtime_name}"></script>') == 0
|
assert build.dashboard_html.count(f'<script src="{workspace.runtime_name}"></script>') == 0
|
||||||
assert len(build.runtime_gzip_bytes) <= 100 * 1024
|
assert len(build.runtime_gzip_bytes) <= 100 * 1024
|
||||||
assert len(workspace.runtime_gzip_bytes) <= 111 * 1024
|
assert len(build.runtime_gzip_bytes) + len(workspace.runtime_gzip_bytes) <= 150 * 1024
|
||||||
|
|
||||||
shell_block = build.service_worker_source.split("const OPTIONAL_FEATURES = [", 1)[0]
|
shell_block = build.service_worker_source.split("const OPTIONAL_FEATURES = [", 1)[0]
|
||||||
assert f"BASE + '{workspace.runtime_name}'" not in shell_block
|
assert f"BASE + '{workspace.runtime_name}'" not in shell_block
|
||||||
|
|
|
||||||
38
tests/test_progressive_my_work.py
Normal file
38
tests/test_progressive_my_work.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
MODULE = Path(__file__).parents[1] / "frontend" / "progressive-my-work.js"
|
||||||
|
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_progressive_my_work_renders_and_filters_assigned_work_before_full_workspace():
|
||||||
|
harness = f"""
|
||||||
|
const fs=require('fs'); const vm=require('vm');
|
||||||
|
const buttons=[
|
||||||
|
{{dataset:{{workFilter:'all'}},attrs:{{}},addEventListener(n,cb){{this.cb=cb;}},setAttribute(n,v){{this.attrs[n]=v;}}}},
|
||||||
|
{{dataset:{{workFilter:'issue'}},attrs:{{}},addEventListener(n,cb){{this.cb=cb;}},setAttribute(n,v){{this.attrs[n]=v;}}}},
|
||||||
|
];
|
||||||
|
const list={{innerHTML:''}}; const status={{textContent:''}};
|
||||||
|
const document={{querySelector:s=>s==='#my-work-list'?list:s==='#my-work-status'?status:null,querySelectorAll:()=>buttons}};
|
||||||
|
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
|
||||||
|
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
||||||
|
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
||||||
|
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
|
||||||
|
const createProgressiveMyWork=context.module.exports;
|
||||||
|
const flow=createProgressiveMyWork({{document,fetchSnapshot:async()=>({{
|
||||||
|
user:{{login:'timmy'}},issues:[{{number:7,title:'Fix mobile queue',repository:'stackchain/dashboard',assignees:['timmy'],url:'https://forge.example/issues/7'}}],pull_requests:[]
|
||||||
|
}})}});
|
||||||
|
(async()=>{{
|
||||||
|
await flow.start(); const rendered=list.innerHTML;
|
||||||
|
buttons[1].cb();
|
||||||
|
console.log(JSON.stringify({{status:status.textContent,rendered,pressed:buttons.map(b=>b.attrs['aria-pressed'])}}));
|
||||||
|
}})().catch(e=>{{console.error(e);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
||||||
|
state = json.loads(result.stdout)
|
||||||
|
assert state["status"] == "1 assigned work item ready."
|
||||||
|
assert "Fix mobile queue" in state["rendered"]
|
||||||
|
assert 'href="https://forge.example/issues/7"' in state["rendered"]
|
||||||
|
assert state["pressed"] == ["false", "true"]
|
||||||
|
|
@ -1408,6 +1408,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/offline-work.js",
|
"/dashboard/static/offline-work.js",
|
||||||
"/dashboard/static/offline-today.js",
|
"/dashboard/static/offline-today.js",
|
||||||
"/dashboard/static/my-work.js",
|
"/dashboard/static/my-work.js",
|
||||||
|
"/dashboard/static/progressive-my-work.js",
|
||||||
"/dashboard/static/agenda-replan.js",
|
"/dashboard/static/agenda-replan.js",
|
||||||
"/dashboard/static/agenda-calendar.js",
|
"/dashboard/static/agenda-calendar.js",
|
||||||
"/dashboard/static/protect-today.js",
|
"/dashboard/static/protect-today.js",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ def test_workspace_bootstrap_loads_content_addressed_feature_before_startup():
|
||||||
const status={textContent:''};
|
const status={textContent:''};
|
||||||
const document={
|
const document={
|
||||||
querySelector(selector) {
|
querySelector(selector) {
|
||||||
|
if (selector === 'meta[name="stackchain-feature-work-core"]') return {content:'feature-work-core-123.js'};
|
||||||
if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'};
|
if (selector === 'meta[name="stackchain-feature-today-timer"]') return {content:'feature-workspace-abc.js'};
|
||||||
if (selector === 'meta[name="stackchain-feature-planning"]') return {content:'feature-planning-def.js'};
|
if (selector === 'meta[name="stackchain-feature-planning"]') return {content:'feature-planning-def.js'};
|
||||||
if (selector === '#my-work-action-status') return status;
|
if (selector === '#my-work-action-status') return status;
|
||||||
|
|
@ -37,6 +38,7 @@ console.log(JSON.stringify({requested,status:status.textContent}));
|
||||||
""")
|
""")
|
||||||
assert result == {
|
assert result == {
|
||||||
"requested": [
|
"requested": [
|
||||||
|
"work-core:feature-work-core-123.js",
|
||||||
"today-timer:feature-workspace-abc.js",
|
"today-timer:feature-workspace-abc.js",
|
||||||
"planning:feature-planning-def.js",
|
"planning:feature-planning-def.js",
|
||||||
],
|
],
|
||||||
|
|
@ -44,6 +46,30 @@ console.log(JSON.stringify({requested,status:status.textContent}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_bootstrap_returns_after_work_core_while_optional_features_hydrate():
|
||||||
|
result = run_bootstrap("""
|
||||||
|
const document={querySelector(selector) {
|
||||||
|
const match=selector.match(/stackchain-feature-([^\"]+)/);
|
||||||
|
return match ? {content:'feature-' + match[1] + '.js'} : null;
|
||||||
|
}};
|
||||||
|
const requested=[]; const releases={};
|
||||||
|
const createLoader=()=>({load:name=>{
|
||||||
|
requested.push(name);
|
||||||
|
if (name === 'work-core') return Promise.resolve();
|
||||||
|
return new Promise(resolve=>{releases[name]=resolve;});
|
||||||
|
}});
|
||||||
|
const lifecycle=await loadWorkspace({document,createLoader});
|
||||||
|
const returned=requested.slice();
|
||||||
|
releases['today-timer'](); releases.planning();
|
||||||
|
await lifecycle.optionalReady;
|
||||||
|
console.log(JSON.stringify({returned,settled:requested}));
|
||||||
|
""")
|
||||||
|
assert result == {
|
||||||
|
"returned": ["work-core", "today-timer", "planning"],
|
||||||
|
"settled": ["work-core", "today-timer", "planning"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_bootstrap_recovers_one_transient_failure_in_place():
|
def test_workspace_bootstrap_recovers_one_transient_failure_in_place():
|
||||||
result = run_bootstrap("""
|
result = run_bootstrap("""
|
||||||
const status={textContent:''}; const retry={hidden:true,disabled:false}; let attempts=0;
|
const status={textContent:''}; const retry={hidden:true,disabled:false}; let attempts=0;
|
||||||
|
|
@ -72,7 +98,7 @@ const document={querySelector(selector) {
|
||||||
return null;
|
return null;
|
||||||
}};
|
}};
|
||||||
const window={location:{reload(){reloads++;}},addEventListener(){},removeEventListener(){}};
|
const window={location:{reload(){reloads++;}},addEventListener(){},removeEventListener(){}};
|
||||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 5) throw new Error('offline');}});
|
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
|
||||||
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
|
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
|
||||||
await new Promise(resolve=>setImmediate(resolve));
|
await new Promise(resolve=>setImmediate(resolve));
|
||||||
const offered={hidden:retry.hidden,disabled:retry.disabled,status:status.textContent};
|
const offered={hidden:retry.hidden,disabled:retry.disabled,status:status.textContent};
|
||||||
|
|
@ -81,7 +107,7 @@ await Promise.all([first,second,loading]);
|
||||||
console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
|
console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
|
||||||
""")
|
""")
|
||||||
assert result == {
|
assert result == {
|
||||||
"attempts": 6,
|
"attempts": 5,
|
||||||
"reloads": 0,
|
"reloads": 0,
|
||||||
"offered": {
|
"offered": {
|
||||||
"hidden": False,
|
"hidden": False,
|
||||||
|
|
@ -116,7 +142,7 @@ const window={
|
||||||
addEventListener(name,callback) { listeners[name]=callback; },
|
addEventListener(name,callback) { listeners[name]=callback; },
|
||||||
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
|
removeEventListener(name,callback) { if (listeners[name] === callback) delete listeners[name]; },
|
||||||
};
|
};
|
||||||
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 5) throw new Error('offline');}});
|
const createLoader=()=>({load:async()=>{attempts++; if (attempts < 3) throw new Error('offline');}});
|
||||||
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
|
const loading=loadWorkspace({document,window,createLoader,schedule:callback=>callback()});
|
||||||
await new Promise(resolve=>setImmediate(resolve));
|
await new Promise(resolve=>setImmediate(resolve));
|
||||||
const waiting=Boolean(listeners.online);
|
const waiting=Boolean(listeners.online);
|
||||||
|
|
@ -125,7 +151,7 @@ await loading;
|
||||||
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
|
console.log(JSON.stringify({attempts,waiting,reloads,status:status.textContent}));
|
||||||
""")
|
""")
|
||||||
assert result == {
|
assert result == {
|
||||||
"attempts": 6,
|
"attempts": 5,
|
||||||
"waiting": True,
|
"waiting": True,
|
||||||
"reloads": 0,
|
"reloads": 0,
|
||||||
"status": "",
|
"status": "",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user