Merge pull request 'Keep the Today queue automatically ready offline' (#350) from timmy/349-offline-today-readiness into main
This commit is contained in:
commit
a995e3b45f
|
|
@ -32,7 +32,13 @@ and durable delivery flow. A different or unconfirmed account can only copy or d
|
||||||
the private content. Issue capture and authored mobile actions (issue
|
the private content. Issue capture and authored mobile actions (issue
|
||||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||||
another worker replays a confirmed result instead of posting duplicate content. Closed-app
|
another worker replays a confirmed result instead of posting duplicate content. When
|
||||||
|
**Keep My Work available offline** is enabled, every issue or pull request in the bounded
|
||||||
|
Today queue is warmed automatically after a healthy authenticated refresh and as soon as
|
||||||
|
it is added. The readiness indicator reports saved, pending, and retryable items; unchanged
|
||||||
|
`updated_at` revisions make no detail request, transient failures retain the prior copy,
|
||||||
|
and pull-request diffs remain online-only.
|
||||||
|
Closed-app
|
||||||
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
|
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
|
||||||
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
|
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
|
||||||
with the unchanged idempotency key. Device purge cancels an active drain before closing
|
with the unchanged idempotency key. Device purge cancels an active drain before closing
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
||||||
.offline-work-controls input { width:20px; height:20px; }
|
.offline-work-controls input { width:20px; height:20px; }
|
||||||
.offline-work-controls button { min-height:44px; }
|
.offline-work-controls button { min-height:44px; }
|
||||||
|
.offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||||
|
.offline-today-readiness[hidden] { display:none; }
|
||||||
|
.offline-today-readiness button { min-height:44px; }
|
||||||
.install-app-card { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding:10px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
.install-app-card { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding:10px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||||
.install-app-card[hidden] { display:none; }
|
.install-app-card[hidden] { display:none; }
|
||||||
.install-app-card p { margin:0; flex:1 1 240px; }
|
.install-app-card p { margin:0; flex:1 1 240px; }
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,32 @@
|
||||||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||||||
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
||||||
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage });
|
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage });
|
||||||
|
function renderOfflineTodayStatus(status) {
|
||||||
|
const container = qs('#offline-today-readiness');
|
||||||
|
const label = qs('#offline-today-status');
|
||||||
|
const retry = qs('#retry-offline-today');
|
||||||
|
const visible = offlineWorkStore.enabled() && status.total > 0;
|
||||||
|
container.hidden = !visible;
|
||||||
|
if (!visible) return;
|
||||||
|
label.textContent = 'Today offline: ' + status.ready + ' of ' + status.total + ' ready' +
|
||||||
|
(status.pending ? ' · saving ' + status.pending : '') +
|
||||||
|
(status.failed ? ' · retry ' + status.failed : '');
|
||||||
|
retry.hidden = status.failed === 0;
|
||||||
|
}
|
||||||
|
const offlineToday = createOfflineToday({
|
||||||
|
loadDetail: item => item.kind === 'pull' ? pullController.load(item) : issueController.load(item),
|
||||||
|
loadSavedDetail: (login, item) => offlineWorkStore.loadDetail(login, item),
|
||||||
|
saveDetail: (login, item, detail) => offlineWorkStore.saveDetail(login, item, detail),
|
||||||
|
onStatus: renderOfflineTodayStatus,
|
||||||
|
});
|
||||||
|
function warmTodayOffline() {
|
||||||
|
if (!offlineWorkStore.enabled() || !confirmedOwnerLogin || offlineWorkMode) {
|
||||||
|
offlineToday.cancel();
|
||||||
|
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return offlineToday.warm(confirmedOwnerLogin, todayMyWork);
|
||||||
|
}
|
||||||
const findWorkController = createFindWork({
|
const findWorkController = createFindWork({
|
||||||
fetchJson: fetchReviewJson,
|
fetchJson: fetchReviewJson,
|
||||||
onItems: renderAvailableIssues,
|
onItems: renderAvailableIssues,
|
||||||
|
|
@ -390,6 +416,7 @@
|
||||||
addToday: item => {
|
addToday: item => {
|
||||||
const result = todayWork.add(item);
|
const result = todayWork.add(item);
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
|
if (result === 'added') warmTodayOffline();
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
onClaimed: item => {
|
onClaimed: item => {
|
||||||
|
|
@ -1036,6 +1063,7 @@
|
||||||
(result === 'added' ? 'Added to Today without changing Gitea.' :
|
(result === 'added' ? 'Added to Today without changing Gitea.' :
|
||||||
(result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.'));
|
(result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.'));
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
|
if (result === 'added') warmTodayOffline();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-today-remove]').forEach(button => {
|
document.querySelectorAll('[data-today-remove]').forEach(button => {
|
||||||
|
|
@ -1933,6 +1961,7 @@
|
||||||
notification_pagination: snapshot.notification_pagination,
|
notification_pagination: snapshot.notification_pagination,
|
||||||
});
|
});
|
||||||
updateOfflineWorkControls();
|
updateOfflineWorkControls();
|
||||||
|
warmTodayOffline();
|
||||||
}
|
}
|
||||||
flushIssueOutbox();
|
flushIssueOutbox();
|
||||||
flushAuthoredOutbox();
|
flushAuthoredOutbox();
|
||||||
|
|
@ -3095,6 +3124,8 @@
|
||||||
notification_pagination:notificationPagination });
|
notification_pagination:notificationPagination });
|
||||||
}
|
}
|
||||||
updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
|
updateOfflineWorkControls(keepWorkOffline.checked ? 'Offline saving enabled.' : 'Offline work data cleared.');
|
||||||
|
if (keepWorkOffline.checked) warmTodayOffline();
|
||||||
|
else offlineToday.cancel();
|
||||||
});
|
});
|
||||||
deliveryReceipts.addEventListener('change', async () => {
|
deliveryReceipts.addEventListener('change', async () => {
|
||||||
let enabled = deliveryReceipts.checked;
|
let enabled = deliveryReceipts.checked;
|
||||||
|
|
@ -3110,8 +3141,13 @@
|
||||||
});
|
});
|
||||||
qs('#clear-offline-work').addEventListener('click', () => {
|
qs('#clear-offline-work').addEventListener('click', () => {
|
||||||
offlineWorkStore.clear();
|
offlineWorkStore.clear();
|
||||||
|
offlineToday.cancel();
|
||||||
|
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
|
||||||
updateOfflineWorkControls('Offline work data cleared.');
|
updateOfflineWorkControls('Offline work data cleared.');
|
||||||
});
|
});
|
||||||
|
qs('#retry-offline-today').addEventListener('click', () =>
|
||||||
|
offlineToday.retry(confirmedOwnerLogin, todayMyWork)
|
||||||
|
);
|
||||||
updateOfflineWorkControls();
|
updateOfflineWorkControls();
|
||||||
updateDeliveryReceiptControls();
|
updateDeliveryReceiptControls();
|
||||||
if (!navigator.onLine) showOfflineStatus();
|
if (!navigator.onLine) showOfflineStatus();
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,10 @@
|
||||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||||
|
<span class="offline-today-readiness" id="offline-today-readiness" hidden>
|
||||||
|
<span class="small" id="offline-today-status" role="status" aria-live="polite"></span>
|
||||||
|
<button id="retry-offline-today" type="button" hidden>Retry offline items</button>
|
||||||
|
</span>
|
||||||
<span class="small" id="delivery-receipt-status" role="status" aria-live="polite"></span>
|
<span class="small" id="delivery-receipt-status" role="status" aria-live="polite"></span>
|
||||||
<div class="install-app-card" id="install-app-card" hidden>
|
<div class="install-app-card" id="install-app-card" hidden>
|
||||||
<p><strong>Install Stackchain</strong><br><span class="small">Keep mobile work one tap away and launch the saved app shell during an outage.</span></p>
|
<p><strong>Install Stackchain</strong><br><span class="small">Keep mobile work one tap away and launch the saved app shell during an outage.</span></p>
|
||||||
|
|
@ -530,6 +534,7 @@
|
||||||
<script src="static/issue-outbox.js"></script>
|
<script src="static/issue-outbox.js"></script>
|
||||||
<script src="static/authored-outbox.js"></script>
|
<script src="static/authored-outbox.js"></script>
|
||||||
<script src="static/offline-work.js"></script>
|
<script src="static/offline-work.js"></script>
|
||||||
|
<script src="static/offline-today.js"></script>
|
||||||
<script src="static/my-work.js"></script>
|
<script src="static/my-work.js"></script>
|
||||||
<script src="static/today-work.js"></script>
|
<script src="static/today-work.js"></script>
|
||||||
<script src="static/update-ownership.js"></script>
|
<script src="static/update-ownership.js"></script>
|
||||||
|
|
|
||||||
83
frontend/offline-today.js
Normal file
83
frontend/offline-today.js
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
(function (root, factory) {
|
||||||
|
const api = factory();
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
else root.createOfflineToday = api;
|
||||||
|
})(typeof self !== 'undefined' ? self : this, function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const itemKey = item => [item?.kind, item?.repository, Number(item?.number || 0)].join(':');
|
||||||
|
|
||||||
|
function createOfflineToday({
|
||||||
|
loadDetail, loadSavedDetail, saveDetail, onStatus = () => {}, concurrency = 2, maxItems = 5,
|
||||||
|
}) {
|
||||||
|
let failed = new Set();
|
||||||
|
let generation = 0;
|
||||||
|
|
||||||
|
function bounded(items) {
|
||||||
|
return (Array.isArray(items) ? items : []).filter(item =>
|
||||||
|
['issue', 'pull'].includes(item?.kind) && item?.repository && Number(item?.number) > 0
|
||||||
|
).slice(0, Math.max(1, maxItems));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(login, sourceItems, onlyFailed) {
|
||||||
|
login = String(login || '').trim();
|
||||||
|
const items = bounded(sourceItems);
|
||||||
|
const runGeneration = ++generation;
|
||||||
|
if (!login || !items.length) {
|
||||||
|
failed = new Set();
|
||||||
|
const empty = { total: items.length, ready: 0, failed: 0, pending: 0 };
|
||||||
|
onStatus(empty);
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousFailed = failed;
|
||||||
|
const readyKeys = new Set(items.filter(item => loadSavedDetail(login, item)).map(itemKey));
|
||||||
|
const snapshot = pending => ({
|
||||||
|
total: items.length, ready: readyKeys.size, failed: failed.size, pending,
|
||||||
|
});
|
||||||
|
const candidates = items.filter(item => {
|
||||||
|
const key = itemKey(item);
|
||||||
|
if (onlyFailed && !previousFailed.has(key)) return false;
|
||||||
|
const saved = loadSavedDetail(login, item);
|
||||||
|
return onlyFailed || !saved || saved.source_updated_at !== item.updated_at;
|
||||||
|
});
|
||||||
|
failed = new Set();
|
||||||
|
onStatus(snapshot(candidates.length));
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (cursor < candidates.length && runGeneration === generation) {
|
||||||
|
const item = candidates[cursor++];
|
||||||
|
try {
|
||||||
|
const detail = await loadDetail(item);
|
||||||
|
if (runGeneration !== generation) return;
|
||||||
|
const saved = saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
|
||||||
|
if (saved !== false) readyKeys.add(itemKey(item));
|
||||||
|
} catch (_error) {
|
||||||
|
if (runGeneration === generation) failed.add(itemKey(item));
|
||||||
|
}
|
||||||
|
if (runGeneration === generation) {
|
||||||
|
onStatus(snapshot(Math.max(0, candidates.length - cursor)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(Array.from(
|
||||||
|
{ length: Math.min(Math.max(1, concurrency), candidates.length) }, worker
|
||||||
|
));
|
||||||
|
if (runGeneration !== generation) return snapshot(0);
|
||||||
|
const status = snapshot(0);
|
||||||
|
onStatus(status);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
warm: (login, items) => run(login, items, false),
|
||||||
|
retry: (login, items) => run(login, items, true),
|
||||||
|
cancel() { generation += 1; failed = new Set(); },
|
||||||
|
failedKeys: () => Array.from(failed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return createOfflineToday;
|
||||||
|
});
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
];
|
];
|
||||||
const DETAIL_FIELDS = [
|
const DETAIL_FIELDS = [
|
||||||
'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone',
|
'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone',
|
||||||
'id', 'repository', 'subject_type', 'subject_body',
|
'id', 'repository', 'subject_type', 'subject_body', 'source_updated_at',
|
||||||
];
|
];
|
||||||
const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url'];
|
const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url'];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v40';
|
const CACHE = 'stackchain-dashboard-shell-v41';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
BASE,
|
BASE,
|
||||||
|
|
@ -20,6 +20,7 @@ const SHELL = [
|
||||||
BASE + 'static/issue-outbox.js',
|
BASE + 'static/issue-outbox.js',
|
||||||
BASE + 'static/authored-outbox.js',
|
BASE + 'static/authored-outbox.js',
|
||||||
BASE + 'static/offline-work.js',
|
BASE + 'static/offline-work.js',
|
||||||
|
BASE + 'static/offline-today.js',
|
||||||
BASE + 'static/my-work.js',
|
BASE + 'static/my-work.js',
|
||||||
BASE + 'static/today-work.js',
|
BASE + 'static/today-work.js',
|
||||||
BASE + 'static/update-ownership.js',
|
BASE + 'static/update-ownership.js',
|
||||||
|
|
|
||||||
|
|
@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" in css
|
assert ".markdown-content a { min-height:44px;" in css
|
||||||
assert "stackchain-dashboard-shell-v40" in worker
|
assert "stackchain-dashboard-shell-v41" in worker
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||||
|
|
||||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||||
assert "stackchain-dashboard-shell-v40" in worker
|
assert "stackchain-dashboard-shell-v41" in worker
|
||||||
|
|
|
||||||
124
tests/test_offline_today.py
Normal file
124
tests/test_offline_today.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.dashboard_bundle import dashboard
|
||||||
|
|
||||||
|
|
||||||
|
OFFLINE_TODAY = Path(__file__).parents[1] / "frontend" / "offline-today.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_scenario(scenario: str) -> dict:
|
||||||
|
script = f"""
|
||||||
|
const createOfflineToday = require({json.dumps(str(OFFLINE_TODAY))});
|
||||||
|
(async () => {{
|
||||||
|
{scenario}
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_warms_bounded_today_queue_with_two_concurrent_requests():
|
||||||
|
result = run_scenario("""
|
||||||
|
let active = 0;
|
||||||
|
let peak = 0;
|
||||||
|
const loaded = [];
|
||||||
|
const saved = [];
|
||||||
|
const items = Array.from({length:7}, (_, index) => ({
|
||||||
|
kind:index % 2 ? 'pull' : 'issue', repository:'stackchain/dashboard', number:index + 1,
|
||||||
|
updated_at:'2026-08-08T12:0' + index + ':00Z',
|
||||||
|
}));
|
||||||
|
const warmer = createOfflineToday({
|
||||||
|
loadDetail: async item => {
|
||||||
|
active += 1;
|
||||||
|
peak = Math.max(peak, active);
|
||||||
|
loaded.push(item.number);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5));
|
||||||
|
active -= 1;
|
||||||
|
return {title:'Work ' + item.number, body:'Context ' + item.number};
|
||||||
|
},
|
||||||
|
loadSavedDetail: () => null,
|
||||||
|
saveDetail: (login, item, detail) => saved.push({login, number:item.number, detail}),
|
||||||
|
});
|
||||||
|
const status = await warmer.warm('timmy', items);
|
||||||
|
process.stdout.write(JSON.stringify({peak, loaded, saved, status}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["peak"] == 2
|
||||||
|
assert result["loaded"] == [1, 2, 3, 4, 5]
|
||||||
|
assert [entry["number"] for entry in result["saved"]] == [1, 2, 3, 4, 5]
|
||||||
|
assert result["saved"][0]["detail"]["source_updated_at"] == "2026-08-08T12:00:00Z"
|
||||||
|
assert result["status"] == {"total": 5, "ready": 5, "failed": 0, "pending": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_refreshes_only_changed_revisions_and_keeps_prior_copy_on_failure():
|
||||||
|
result = run_scenario("""
|
||||||
|
const items = [
|
||||||
|
{kind:'issue', repository:'stackchain/dashboard', number:1, updated_at:'new'},
|
||||||
|
{kind:'pull', repository:'stackchain/dashboard', number:2, updated_at:'same'},
|
||||||
|
];
|
||||||
|
const old = new Map([
|
||||||
|
['issue:stackchain/dashboard:1', {title:'Prior 1', source_updated_at:'old'}],
|
||||||
|
['pull:stackchain/dashboard:2', {title:'Prior 2', source_updated_at:'same'}],
|
||||||
|
]);
|
||||||
|
const loaded = [];
|
||||||
|
const warmer = createOfflineToday({
|
||||||
|
loadDetail: async item => { loaded.push(item.number); throw new Error('temporary outage'); },
|
||||||
|
loadSavedDetail: (_login, item) => old.get(item.kind + ':' + item.repository + ':' + item.number),
|
||||||
|
saveDetail: () => { throw new Error('must not replace a prior copy after failure'); },
|
||||||
|
});
|
||||||
|
const status = await warmer.warm('timmy', items);
|
||||||
|
process.stdout.write(JSON.stringify({loaded, status, failed:warmer.failedKeys()}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"loaded": [1],
|
||||||
|
"status": {"total": 2, "ready": 2, "failed": 1, "pending": 0},
|
||||||
|
"failed": ["issue:stackchain/dashboard:1"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_fetches_only_failed_items():
|
||||||
|
result = run_scenario("""
|
||||||
|
const items = [
|
||||||
|
{kind:'issue', repository:'stackchain/dashboard', number:1, updated_at:'v2'},
|
||||||
|
{kind:'issue', repository:'stackchain/dashboard', number:2, updated_at:'v1'},
|
||||||
|
];
|
||||||
|
let fail = true;
|
||||||
|
const calls = [];
|
||||||
|
const saved = new Map([['issue:stackchain/dashboard:2', {source_updated_at:'v1'}]]);
|
||||||
|
const warmer = createOfflineToday({
|
||||||
|
loadDetail: async item => {
|
||||||
|
calls.push(item.number);
|
||||||
|
if (fail) throw new Error('offline');
|
||||||
|
return {title:'Fresh'};
|
||||||
|
},
|
||||||
|
loadSavedDetail: (_login, item) => saved.get(item.kind + ':' + item.repository + ':' + item.number),
|
||||||
|
saveDetail: (_login, item, detail) => saved.set(item.kind + ':' + item.repository + ':' + item.number, detail),
|
||||||
|
});
|
||||||
|
await warmer.warm('timmy', items);
|
||||||
|
fail = false;
|
||||||
|
const status = await warmer.retry('timmy', items);
|
||||||
|
process.stdout.write(JSON.stringify({calls, status}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result["calls"] == [1, 1]
|
||||||
|
assert result["status"] == {"total": 2, "ready": 2, "failed": 0, "pending": 0}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_dashboard_warms_today_after_fresh_snapshot_and_exposes_mobile_retry():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert '<script src="static/offline-today.js"></script>' in html
|
||||||
|
assert 'id="offline-today-status"' in html
|
||||||
|
assert 'id="retry-offline-today"' in html
|
||||||
|
assert "offlineToday.warm(confirmedOwnerLogin, todayMyWork)" in html
|
||||||
|
assert "offlineToday.retry(confirmedOwnerLogin, todayMyWork)" in html
|
||||||
|
assert "Today offline: " in html
|
||||||
|
assert ".offline-today-readiness button { min-height:44px;" in html
|
||||||
|
|
@ -97,7 +97,7 @@ async function dispatchNotificationClick(route) {{
|
||||||
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
|
def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v40" in source
|
assert "stackchain-dashboard-shell-v41" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
@ -106,14 +106,14 @@ def test_share_target_sign_in_fix_ships_in_a_new_shell_cache():
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v40" in source
|
assert "stackchain-dashboard-shell-v41" in source
|
||||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v40" in source
|
assert "stackchain-dashboard-shell-v41" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -304,6 +304,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/issue-outbox.js",
|
"/dashboard/static/issue-outbox.js",
|
||||||
"/dashboard/static/authored-outbox.js",
|
"/dashboard/static/authored-outbox.js",
|
||||||
"/dashboard/static/offline-work.js",
|
"/dashboard/static/offline-work.js",
|
||||||
|
"/dashboard/static/offline-today.js",
|
||||||
"/dashboard/static/my-work.js",
|
"/dashboard/static/my-work.js",
|
||||||
"/dashboard/static/today-work.js",
|
"/dashboard/static/today-work.js",
|
||||||
"/dashboard/static/update-ownership.js",
|
"/dashboard/static/update-ownership.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user