Merge pull request 'Demand-load Today and Planning workspace on mobile' (#1469) from timmy/1468-demand-load-mobile-workspace into main
Merge pull request 'Demand-load Today and Planning workspace on mobile' (#1469)
This commit is contained in:
commit
46e06c1a37
|
|
@ -126,7 +126,7 @@ function createContextPoller({
|
||||||
|
|
||||||
let request;
|
let request;
|
||||||
try {
|
try {
|
||||||
request = fetchContext({ ...revisions }, { signal: controller.signal });
|
request = fetchContext(options.full ? {} : { ...revisions }, { signal: controller.signal });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
request = Promise.reject(error);
|
request = Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -358,6 +358,7 @@
|
||||||
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
|
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
|
||||||
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
|
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
|
||||||
let liveMode = true;
|
let liveMode = true;
|
||||||
|
let initialAccountRecovery = Promise.resolve(false);
|
||||||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
||||||
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.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'];
|
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() : '';
|
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||||
if (activeFlushLogin) {
|
if (activeFlushLogin) {
|
||||||
confirmedOwnerLogin = activeFlushLogin;
|
confirmedOwnerLogin = activeFlushLogin;
|
||||||
|
planningOwnerLogin = activeFlushLogin;
|
||||||
|
planningOwnerAccountKey = snapshot.context.user?.id ?
|
||||||
|
String(snapshot.context.user.id) + ':' + activeFlushLogin : '';
|
||||||
mobileQueuePriority.render();
|
mobileQueuePriority.render();
|
||||||
renderMobileQueuePresentation();
|
renderMobileQueuePresentation();
|
||||||
void mobileQueuePriority.load();
|
void mobileQueuePriority.load();
|
||||||
|
|
@ -5666,7 +5670,10 @@
|
||||||
if (planningOwnerLogin) {
|
if (planningOwnerLogin) {
|
||||||
syncPendingTomorrow();
|
syncPendingTomorrow();
|
||||||
todaySync.migrate(todayWork.read());
|
todaySync.migrate(todayWork.read());
|
||||||
todaySync.flush();
|
initialAccountRecovery = Promise.all([
|
||||||
|
initialAccountRecovery,
|
||||||
|
todaySync.flush(),
|
||||||
|
]).then(() => true);
|
||||||
laterSync.migrate(laterWork.read());
|
laterSync.migrate(laterWork.read());
|
||||||
laterSync.flush();
|
laterSync.flush();
|
||||||
}
|
}
|
||||||
|
|
@ -8472,7 +8479,8 @@
|
||||||
});
|
});
|
||||||
let pushControllerReady = Promise.resolve(null);
|
let pushControllerReady = Promise.resolve(null);
|
||||||
if ('serviceWorker' in navigator) {
|
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');
|
await issueCaptureFeatures.load('push-notifications');
|
||||||
const controller = createPushNotifications({
|
const controller = createPushNotifications({
|
||||||
control:qs('#push-updates'),
|
control:qs('#push-updates'),
|
||||||
|
|
@ -8596,6 +8604,20 @@
|
||||||
adoptedProgressiveSnapshot = await contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise);
|
adoptedProgressiveSnapshot = await contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise);
|
||||||
}
|
}
|
||||||
if (!adoptedProgressiveSnapshot) await load();
|
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) {
|
if (progressiveWorkHandoff?.openWork) {
|
||||||
const progressiveItem = lastMyWork.find(item =>
|
const progressiveItem = lastMyWork.find(item =>
|
||||||
item.repository === progressiveWorkHandoff.openWork.repository &&
|
item.repository === progressiveWorkHandoff.openWork.repository &&
|
||||||
|
|
@ -8614,4 +8636,8 @@
|
||||||
|
|
||||||
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
||||||
setInterval(widgetTick, 1000);
|
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 OPTIONAL_FEATURES = [
|
||||||
];
|
];
|
||||||
|
const DEMAND_FEATURES = [
|
||||||
|
];
|
||||||
|
|
||||||
const SHARED_IMAGE_ID = 'shared-image';
|
const SHARED_IMAGE_ID = 'shared-image';
|
||||||
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
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)));
|
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||||
return;
|
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));
|
event.respondWith(cachedOptionalFeature(request));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -489,7 +489,10 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRu
|
||||||
const snapshot = timer.snapshot();
|
const snapshot = timer.snapshot();
|
||||||
breakView?.render();
|
breakView?.render();
|
||||||
const active = Boolean(progress && isActive() && snapshot.identity);
|
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 => {
|
queryAll('[data-mobile-today-open]').forEach(element => {
|
||||||
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
|
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,15 @@ async function loadWorkspace({
|
||||||
const failed = new Set();
|
const failed = new Set();
|
||||||
const recoveries = new Map();
|
const recoveries = new Map();
|
||||||
let retryInFlight = null;
|
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 => {
|
const loadFeature = name => {
|
||||||
attempts[name] += 1;
|
attempts[name] += 1;
|
||||||
|
|
@ -63,6 +72,8 @@ async function loadWorkspace({
|
||||||
retryButton?.addEventListener?.('click', retryFailed);
|
retryButton?.addEventListener?.('click', retryFailed);
|
||||||
const handleOnline = () => { cameOnline = true; void retryFailed(); };
|
const handleOnline = () => { cameOnline = true; void retryFailed(); };
|
||||||
window?.addEventListener('online', handleOnline);
|
window?.addEventListener('online', handleOnline);
|
||||||
|
const serviceWorkerReady = window?.navigator?.serviceWorker?.register ?
|
||||||
|
window.navigator.serviceWorker.register('service-worker.js') : Promise.resolve(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await retryOnce('work-core');
|
await retryOnce('work-core');
|
||||||
|
|
@ -73,23 +84,56 @@ async function loadWorkspace({
|
||||||
}
|
}
|
||||||
hideRecovery();
|
hideRecovery();
|
||||||
|
|
||||||
const optional = ['today-timer', 'planning'].map(async name => {
|
let optionalReady = null;
|
||||||
try {
|
const hydrateWorkspace = () => {
|
||||||
await retryOnce(name);
|
if (optionalReady) return optionalReady;
|
||||||
return true;
|
let resolveOptional;
|
||||||
} catch (_error) {
|
optionalReady = new Promise(resolve => { resolveOptional = resolve; });
|
||||||
failed.add(name);
|
const optional = ['today-timer', 'planning'].map(async name => {
|
||||||
showRecovery();
|
try {
|
||||||
return new Promise(resolve => recoveries.set(name, {resolve}));
|
await retryOnce(name);
|
||||||
}
|
return true;
|
||||||
});
|
} catch (_error) {
|
||||||
const optionalReady = Promise.all(optional).then(() => {
|
failed.add(name);
|
||||||
hideRecovery();
|
showRecovery();
|
||||||
return true;
|
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 {
|
return {
|
||||||
optionalReady,
|
hydrateWorkspace,
|
||||||
|
deepLinkReady,
|
||||||
|
workspaceReady,
|
||||||
|
markWorkspaceReady,
|
||||||
|
serviceWorkerReady,
|
||||||
|
get optionalReady() { return hydrateWorkspace(); },
|
||||||
retryFeature(name) {
|
retryFeature(name) {
|
||||||
if (!failed.has(name)) return Promise.resolve(true);
|
if (!failed.has(name)) return Promise.resolve(true);
|
||||||
return retryFailed().then(() => !failed.has(name));
|
return retryFailed().then(() => !failed.has(name));
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,16 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||||
for source in sources:
|
for source in sources:
|
||||||
if source != WORKER_RUNTIME_SOURCE:
|
if source != WORKER_RUNTIME_SOURCE:
|
||||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
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(
|
worker = worker.replace(
|
||||||
" BASE + 'static/dashboard.css',\n",
|
" BASE + 'static/dashboard.css',\n",
|
||||||
" 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"
|
"const OPTIONAL_FEATURES = [\n"
|
||||||
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
|
+ "".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(
|
worker = CACHE_DECLARATION.sub(
|
||||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
"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 playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
from fake_gitea import FakeGiteaServer
|
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)])
|
@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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
|
||||||
page.evaluate("document.querySelector('#open-human-gates').click()")
|
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||||
expect(page.locator("#human-gates")).to_be_visible()
|
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()
|
popup.close()
|
||||||
|
|
||||||
page.reload(wait_until="networkidle")
|
page.reload(wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
page.evaluate("document.querySelector('#open-human-gates').click()")
|
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||||
expect(page.locator("#human-gates")).to_be_visible()
|
expect(page.locator("#human-gates")).to_be_visible()
|
||||||
expect(page.locator('[data-gate-checklist="exact_hash"]')).to_be_checked()
|
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
|
assert len(decision_requests) == 1
|
||||||
|
|
||||||
page.reload(wait_until="networkidle")
|
page.reload(wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
page.evaluate("document.querySelector('#open-human-gates').click()")
|
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||||
expect(page.locator("#human-gates")).to_be_visible()
|
expect(page.locator("#human-gates")).to_be_visible()
|
||||||
expect(page.locator("#human-gate-detail")).to_contain_text("Decision outcome unknown")
|
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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
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()")
|
page.evaluate("document.querySelector('#open-human-gates').click()")
|
||||||
expect(page.locator("#human-gates")).to_be_visible()
|
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()
|
page.locator("#human-gates-history").click()
|
||||||
expect(page.locator("#human-gates-status")).to_contain_text("Older decisions are available")
|
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-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("#human-gates-status")).to_have_text("All 3 Human Gate decisions loaded.")
|
||||||
expect(page.locator("[data-human-gate-history-more]")).to_have_count(0)
|
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")
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
for selector in ("#human-gates-pending", "#human-gates-history"):
|
for selector in ("#human-gates-pending", "#human-gates-history"):
|
||||||
bounds = page.locator(selector).bounding_box()
|
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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
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")
|
expect(page.locator("#my-work-status")).to_contain_text("No assigned work")
|
||||||
|
|
||||||
page.locator('[data-mobile-task="work"]').click()
|
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(sheet).to_be_hidden()
|
||||||
expect(page.locator("#find-work-sheet")).to_be_visible()
|
expect(page.locator("#find-work-sheet")).to_be_visible()
|
||||||
page.locator("#close-find-work").click()
|
page.locator("#close-find-work").click()
|
||||||
|
expect(page.locator("#find-work-sheet")).to_be_hidden()
|
||||||
|
|
||||||
context.set_offline(True)
|
context.set_offline(True)
|
||||||
page.locator('[data-mobile-task="work"]').click()
|
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()
|
expect(page.locator("#mobile-first-task-create")).to_be_focused()
|
||||||
|
|
||||||
page.locator("#close-mobile-first-task").click()
|
page.locator("#close-mobile-first-task").click()
|
||||||
|
expect(sheet).to_be_hidden()
|
||||||
context.set_offline(False)
|
context.set_offline(False)
|
||||||
page.evaluate(
|
probe_result = page.evaluate(
|
||||||
"""() => {
|
"""() => {
|
||||||
document.querySelector('[data-mobile-today-hud]').hidden = false;
|
document.querySelector('[data-mobile-today-hud]').hidden = false;
|
||||||
localStorage.setItem('stackchain.first-task.v1:timmy', 'coaching');
|
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,
|
getLogin: () => 'timmy', hasWork: () => true, isTodayActive: () => true,
|
||||||
coach: isolatedCoach,
|
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]")
|
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")
|
expect(coach).to_contain_text("Complete your first task")
|
||||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
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(page.locator("#mobile-first-task-receipt")).to_be_visible()
|
||||||
expect(coach).to_be_hidden()
|
expect(coach).to_be_hidden()
|
||||||
expect(page.locator("#today-sync-status")).to_contain_text("Today saved to account")
|
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")
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
|
||||||
fresh_context = browser.new_context(
|
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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
fresh_page.locator("#submit-sign-in").click()
|
fresh_page.locator("#submit-sign-in").click()
|
||||||
fresh_page.wait_for_url(origin + "/", wait_until="networkidle")
|
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")
|
expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work")
|
||||||
|
fresh_page.wait_for_load_state("networkidle")
|
||||||
assert fresh_page.evaluate(
|
assert fresh_page.evaluate(
|
||||||
"localStorage.getItem('stackchain.first-task.v1:timmy')"
|
"localStorage.getItem('stackchain.first-task.v1:timmy')"
|
||||||
) == "complete"
|
) == "complete"
|
||||||
|
|
@ -128,6 +176,7 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||||
browser_errors: list[str] = []
|
browser_errors: list[str] = []
|
||||||
failed_responses: list[str] = []
|
failed_responses: list[str] = []
|
||||||
workspace_requests: list[str] = []
|
workspace_requests: list[str] = []
|
||||||
|
optional_workspace_requests: list[str] = []
|
||||||
launch_transfer_events: list[str] = []
|
launch_transfer_events: list[str] = []
|
||||||
live_requests: 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,
|
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(
|
page.on(
|
||||||
"requestfinished",
|
"requestfinished",
|
||||||
lambda request: launch_transfer_events.append("core-finished")
|
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")
|
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="device_label"]').fill("Home bootstrap release phone")
|
||||||
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
live_requests.clear()
|
live_requests.clear()
|
||||||
|
|
@ -194,9 +250,24 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||||
), launch_transfer_events
|
), launch_transfer_events
|
||||||
|
|
||||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
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]
|
revisionless_live_requests = [url for url in live_requests if "?" not in url]
|
||||||
assert len(revisionless_live_requests) == 1, live_requests
|
assert len(revisionless_live_requests) == 1, live_requests
|
||||||
initial_live_requests = len(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(
|
page.evaluate(
|
||||||
"""
|
"""
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -226,18 +297,6 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||||
bounds = control.bounding_box()
|
bounds = control.bounding_box()
|
||||||
assert bounds and bounds["height"] >= 44
|
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('[data-mobile-task="queues"]').click()
|
||||||
page.locator(".mobile-queue-all summary").click()
|
page.locator(".mobile-queue-all summary").click()
|
||||||
delivery_queue = page.locator('[data-mobile-queue="delivery"]')
|
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:
|
try:
|
||||||
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||||
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
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")
|
page.goto(origin + "/", wait_until="networkidle")
|
||||||
|
|
||||||
def interrupt_once(route):
|
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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
page.locator('[data-mobile-task="find"]').click()
|
||||||
|
|
||||||
page.wait_for_timeout(1500)
|
page.wait_for_timeout(1500)
|
||||||
resources = page.evaluate(
|
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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
page.evaluate(
|
||||||
|
"async () => { const lifecycle = await window.stackchainWorkspaceLifecycle; "
|
||||||
|
"await lifecycle.hydrateWorkspace(); await lifecycle.workspaceReady; }"
|
||||||
|
)
|
||||||
|
|
||||||
page.evaluate(
|
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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
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-task="queues"]').click()
|
||||||
page.locator('[data-mobile-queue="agenda"]').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()
|
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]:
|
def indexed_issue_records(page: Page) -> list[dict]:
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
"""async () => {
|
"""async () => {
|
||||||
|
|
@ -275,6 +282,7 @@ def test_release_artifact_files_one_mobile_issue_exactly_once_after_offline_relo
|
||||||
assert fake.created_issues == []
|
assert fake.created_issues == []
|
||||||
|
|
||||||
page.reload(wait_until="domcontentloaded")
|
page.reload(wait_until="domcontentloaded")
|
||||||
|
hydrate_workspace(page)
|
||||||
expect(page.locator('[data-mobile-task="new"]')).to_be_visible()
|
expect(page.locator('[data-mobile-task="new"]')).to_be_visible()
|
||||||
durable_after_reload = indexed_issue_records(page)
|
durable_after_reload = indexed_issue_records(page)
|
||||||
assert [(item["title"], item["body"]) for item in durable_after_reload] == [(TITLE, BODY)]
|
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)
|
page.wait_for_timeout(100)
|
||||||
assert durable_completion and all(item.get("status") == "sent" for item in durable_completion)
|
assert durable_completion and all(item.get("status") == "sent" for item in durable_completion)
|
||||||
page.reload(wait_until="networkidle")
|
page.reload(wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
page.evaluate("window.dispatchEvent(new Event('online'))")
|
page.evaluate("window.dispatchEvent(new Event('online'))")
|
||||||
for _ in range(40):
|
for _ in range(40):
|
||||||
completed_local = json.loads(page.evaluate("localStorage.getItem('stackchain.issue-outbox.v1')"))
|
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 playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
from fake_gitea import FakeGiteaServer
|
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):
|
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")
|
page.reload(wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
stored = page.evaluate(
|
stored = page.evaluate(
|
||||||
"""() => new Promise((resolve,reject)=>{
|
"""() => new Promise((resolve,reject)=>{
|
||||||
const request=indexedDB.open('stackchain-conversation-reply-drafts-v1',1);
|
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 playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
from fake_gitea import FakeGiteaServer
|
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):
|
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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
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');
|
localStorage.setItem(prefix + 'broken', '{not-json');
|
||||||
}""")
|
}""")
|
||||||
page.reload(wait_until="networkidle")
|
page.reload(wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||||
expect(page.locator("#today-sync-status")).to_have_text(
|
expect(page.locator("#today-sync-status")).to_have_text(
|
||||||
"Today queue recovered · discarded 1 unreadable device record."
|
"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(
|
assert page.evaluate("""() => !Object.keys(localStorage).some(
|
||||||
key => key.startsWith('stackchain.today-sync.v1.timmy.operation.')
|
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":
|
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
|
||||||
page.locator("#close-issue-sheet").click()
|
page.locator("#close-issue-sheet").click()
|
||||||
if page.locator("#plan-today-sheet").is_visible():
|
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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||||
|
|
||||||
page.locator('[data-mobile-task="work"]').click()
|
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';
|
return timer.entries[timer.active_identity].running === false && timer.detour_interruption?.reason === 'insights';
|
||||||
}""")
|
}""")
|
||||||
page.reload(wait_until="networkidle")
|
page.reload(wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
restored_pause = page.locator("#insights-sheet > [data-today-detour]")
|
restored_pause = page.locator("#insights-sheet > [data-today-detour]")
|
||||||
expect(restored_pause).to_be_visible()
|
expect(restored_pause).to_be_visible()
|
||||||
expect(restored_pause).to_contain_text("Today paused · Ship mobile capture")
|
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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||||
|
|
||||||
page.locator('[data-mobile-task="work"]').click()
|
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")
|
page.reload(wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
expect(page.locator("#my-work-status")).to_contain_text("2")
|
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||||
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
|
if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open":
|
||||||
page.locator("#close-issue-sheet").click()
|
page.locator("#close-issue-sheet").click()
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
|
||||||
from playwright.sync_api import expect, sync_playwright
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
from fake_gitea import FakeGiteaServer
|
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)])
|
@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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
|
||||||
page.evaluate(
|
page.evaluate(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
|
||||||
from playwright.sync_api import expect, sync_playwright
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
from fake_gitea import FakeGiteaServer
|
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)])
|
@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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
|
||||||
page.evaluate(
|
page.evaluate(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ pytest.importorskip("playwright.sync_api")
|
||||||
from playwright.sync_api import expect, sync_playwright
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
from fake_gitea import FakeGiteaServer
|
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)])
|
@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('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
page.locator("#submit-sign-in").click()
|
page.locator("#submit-sign-in").click()
|
||||||
page.wait_for_url(origin + "/", wait_until="networkidle")
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
|
||||||
def stage_phone_plan():
|
def stage_phone_plan():
|
||||||
page.evaluate(
|
page.evaluate(
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ pytest.importorskip("playwright.sync_api")
|
||||||
from playwright.sync_api import expect, sync_playwright
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
from fake_gitea import FakeGiteaServer
|
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):
|
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:'
|
'issue:acme/mobile:41:', 'issue:acme/mobile:42:'
|
||||||
]))""")
|
]))""")
|
||||||
page.reload(wait_until="networkidle")
|
page.reload(wait_until="networkidle")
|
||||||
|
hydrate_workspace(page)
|
||||||
|
|
||||||
dialog = page.locator("#today-handoff-dialog")
|
dialog = page.locator("#today-handoff-dialog")
|
||||||
expect(dialog).to_be_visible()
|
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():
|
def test_context_poller_adopts_progressive_snapshot_before_revision_conditional_refresh():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
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 "let adoptedProgressiveSnapshot = contextPoller.adopt(progressiveWorkHandoff?.liveSnapshot);" in source
|
||||||
assert "contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise)" in source
|
assert "contextPoller.adoptPending(progressiveWorkHandoff.liveSnapshotPromise)" in source
|
||||||
assert "if (!adoptedProgressiveSnapshot) await load();" 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():
|
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.startswith("runtime-")
|
||||||
assert first.runtime_name.endswith(".js")
|
assert first.runtime_name.endswith(".js")
|
||||||
assert len(gzip.compress(first.runtime_bytes, mtime=0)) <= 100 * 1024
|
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"
|
changed_frontend = tmp_path / "frontend"
|
||||||
shutil.copytree(FRONTEND, changed_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]
|
optional_block = optional_block.split("];", 1)[0]
|
||||||
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" not in shell_block
|
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():
|
for name, bundle in first.feature_bundles.items():
|
||||||
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
|
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"
|
changed_frontend = tmp_path / "frontend"
|
||||||
shutil.copytree(FRONTEND, changed_frontend)
|
shutil.copytree(FRONTEND, changed_frontend)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const loadWorkspace = require({json.dumps(str(BOOTSTRAP))});
|
||||||
return json.loads(completed.stdout)
|
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("""
|
result = run_bootstrap("""
|
||||||
const status={textContent:''};
|
const status={textContent:''};
|
||||||
const document={
|
const document={
|
||||||
|
|
@ -33,11 +33,14 @@ const document={
|
||||||
};
|
};
|
||||||
const requested=[];
|
const requested=[];
|
||||||
const createLoader=options=>({load:async name=>{requested.push(name + ':' + options.urls[name]);}});
|
const createLoader=options=>({load:async name=>{requested.push(name + ':' + options.urls[name]);}});
|
||||||
await loadWorkspace({document,createLoader});
|
const lifecycle=await loadWorkspace({document,createLoader});
|
||||||
console.log(JSON.stringify({requested,status:status.textContent}));
|
const before=requested.slice();
|
||||||
|
await lifecycle.hydrateWorkspace?.();
|
||||||
|
console.log(JSON.stringify({before,after:requested,status:status.textContent}));
|
||||||
""")
|
""")
|
||||||
assert result == {
|
assert result == {
|
||||||
"requested": [
|
"before": ["work-core:feature-work-core-123.js"],
|
||||||
|
"after": [
|
||||||
"work-core:feature-work-core-123.js",
|
"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",
|
||||||
|
|
@ -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("""
|
result = run_bootstrap("""
|
||||||
const document={querySelector(selector) {
|
const document={querySelector(selector) {
|
||||||
const match=selector.match(/stackchain-feature-([^\"]+)/);
|
const match=selector.match(/stackchain-feature-([^\"]+)/);
|
||||||
|
|
@ -60,12 +121,13 @@ const createLoader=()=>({load:name=>{
|
||||||
}});
|
}});
|
||||||
const lifecycle=await loadWorkspace({document,createLoader});
|
const lifecycle=await loadWorkspace({document,createLoader});
|
||||||
const returned=requested.slice();
|
const returned=requested.slice();
|
||||||
|
const hydration=lifecycle.hydrateWorkspace();
|
||||||
releases['today-timer'](); releases.planning();
|
releases['today-timer'](); releases.planning();
|
||||||
await lifecycle.optionalReady;
|
await hydration;
|
||||||
console.log(JSON.stringify({returned,settled:requested}));
|
console.log(JSON.stringify({returned,settled:requested}));
|
||||||
""")
|
""")
|
||||||
assert result == {
|
assert result == {
|
||||||
"returned": ["work-core", "today-timer", "planning"],
|
"returned": ["work-core"],
|
||||||
"settled": ["work-core", "today-timer", "planning"],
|
"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 createLoader=()=>({load:async()=>{attempts++; if (attempts === 1) throw new Error('brief outage');}});
|
||||||
const schedule=callback=>{callback();};
|
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}));
|
console.log(JSON.stringify({attempts,status:status.textContent,retryHidden:retry.hidden}));
|
||||||
""")
|
""")
|
||||||
assert result == {"attempts": 4, "status": "", "retryHidden": True}
|
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}));
|
console.log(JSON.stringify({attempts,reloads,offered,status:status.textContent,retryHidden:retry.hidden}));
|
||||||
""")
|
""")
|
||||||
assert result == {
|
assert result == {
|
||||||
"attempts": 5,
|
"attempts": 3,
|
||||||
"reloads": 0,
|
"reloads": 0,
|
||||||
"offered": {
|
"offered": {
|
||||||
"hidden": False,
|
"hidden": False,
|
||||||
|
|
@ -151,7 +214,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": 5,
|
"attempts": 3,
|
||||||
"waiting": True,
|
"waiting": True,
|
||||||
"reloads": 0,
|
"reloads": 0,
|
||||||
"status": "",
|
"status": "",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user