Demand-load Today and Planning workspace on mobile #1469
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -358,6 +358,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'];
|
||||
|
|
@ -5644,6 +5645,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();
|
||||
|
|
@ -5666,7 +5670,10 @@
|
|||
if (planningOwnerLogin) {
|
||||
syncPendingTomorrow();
|
||||
todaySync.migrate(todayWork.read());
|
||||
todaySync.flush();
|
||||
initialAccountRecovery = Promise.all([
|
||||
initialAccountRecovery,
|
||||
todaySync.flush(),
|
||||
]).then(() => true);
|
||||
laterSync.migrate(laterWork.read());
|
||||
laterSync.flush();
|
||||
}
|
||||
|
|
@ -8472,7 +8479,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'),
|
||||
|
|
@ -8596,6 +8604,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 &&
|
||||
|
|
@ -8614,4 +8636,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?.();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -301,6 +301,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 +1029,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));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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') : '';
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user