feat: make My Work refresh resumable (#119)
This commit is contained in:
parent
b5830cf86f
commit
32d9d9413f
73
frontend/context-poller.js
Normal file
73
frontend/context-poller.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
function createContextPoller({
|
||||
fetchContext,
|
||||
onSnapshot,
|
||||
onError,
|
||||
isHidden = () => false,
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
intervalMs = 8000,
|
||||
}) {
|
||||
let inFlight = null;
|
||||
let timer = null;
|
||||
let stopped = false;
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
cancelTimer();
|
||||
if (stopped || isHidden()) return;
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
refresh();
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (stopped || isHidden()) return Promise.resolve(null);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
let request;
|
||||
try {
|
||||
request = fetchContext();
|
||||
} catch (error) {
|
||||
request = Promise.reject(error);
|
||||
}
|
||||
inFlight = Promise.resolve(request)
|
||||
.then((snapshot) => {
|
||||
onSnapshot(snapshot);
|
||||
return snapshot;
|
||||
})
|
||||
.catch((error) => {
|
||||
onError(error);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
schedule();
|
||||
});
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
function setVisible(visible) {
|
||||
cancelTimer();
|
||||
if (!visible) return Promise.resolve(null);
|
||||
return refresh();
|
||||
}
|
||||
|
||||
return {
|
||||
start: refresh,
|
||||
refresh,
|
||||
setVisible,
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelTimer();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createContextPoller;
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.event:last-child { border-bottom: 0; }
|
||||
.my-work { grid-column: 1 / -1; }
|
||||
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.work-filters { display:flex; gap:8px; }
|
||||
.work-filters { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.work-filter { min-height: 44px; }
|
||||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
|
|
@ -69,7 +69,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
.my-work { margin:0; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.work-filters { width:100%; }
|
||||
.work-filter { flex:1; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
|
|
@ -96,10 +96,10 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
|
||||
</div>
|
||||
<div class="work-filters" aria-label="Filter My Work">
|
||||
<button class="work-filter" data-work-filter="all" aria-pressed="true">All</button>
|
||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues</button>
|
||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs</button>
|
||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews</button>
|
||||
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
|
||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
|
||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
|
||||
<button class="work-filter" data-work-filter="review" aria-pressed="false">Reviews <span data-work-count="review">0</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-work-list" id="my-work-list"></div>
|
||||
|
|
@ -195,6 +195,7 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
<script src="static/commands.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/context-poller.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
const qs = (s, el=document) => el.querySelector(s);
|
||||
|
|
@ -221,56 +222,76 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
});
|
||||
});
|
||||
let liveMode = true;
|
||||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
||||
let selectedWorkFilter = 'all';
|
||||
try {
|
||||
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
|
||||
if (['all', 'issue', 'pull', 'review'].includes(savedFilter)) selectedWorkFilter = savedFilter;
|
||||
} catch (e) {
|
||||
console.warn('Could not restore My Work filter', e);
|
||||
}
|
||||
let lastMyWork = [];
|
||||
let hasContextSnapshot = false;
|
||||
|
||||
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
|
||||
function setClock() { qs('#clock').textContent = fmt(new Date()); }
|
||||
setClock(); setInterval(setClock, 1000);
|
||||
|
||||
async function load() {
|
||||
setStatus('Loading…');
|
||||
try {
|
||||
const res = await fetch('api/v1/context', { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const data = await res.json();
|
||||
liveMode = true;
|
||||
if (data.error && lastMyWork.length) markMyWorkStale();
|
||||
else paintMyWork(data);
|
||||
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
|
||||
'<div class="label">Repos</div><div class="value">' + (data.repos?.length || 0) + '</div>' +
|
||||
'<div class="label">Issues</div><div class="value">' + (data.issues?.length || 0) + '</div>' +
|
||||
'<div class="label">PRs</div><div class="value">' + (data.pull_requests?.length || 0) + '</div></div>';
|
||||
qs('#view-hint').textContent = 'Active view: ' + (data.view || 'dashboard');
|
||||
async function fetchContextSnapshot() {
|
||||
const res = await fetch('api/v1/context', { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const issuesBox = qs('#issues-content');
|
||||
const openIssues = (data.issues || []).filter(i => i.state === 'open').slice(0, 12);
|
||||
issuesBox.innerHTML = (openIssues.length ? openIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '" target="_blank">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' +
|
||||
'<div class="muted">' + (i.labels || []).map(l => '<span class="pill">' + escapeHtml(String(l)) + '</span>').join(' ') + '</div></div>').join('') : '<div class="muted">No open issues.</div>');
|
||||
function renderContextSnapshot(data) {
|
||||
liveMode = true;
|
||||
hasContextSnapshot = true;
|
||||
if (data.error && lastMyWork.length) markMyWorkStale();
|
||||
else paintMyWork(data);
|
||||
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
|
||||
'<div class="label">Repos</div><div class="value">' + (data.repos?.length || 0) + '</div>' +
|
||||
'<div class="label">Issues</div><div class="value">' + (data.issues?.length || 0) + '</div>' +
|
||||
'<div class="label">PRs</div><div class="value">' + (data.pull_requests?.length || 0) + '</div></div>';
|
||||
qs('#view-hint').textContent = 'Active view: ' + (data.view || 'dashboard');
|
||||
|
||||
const prsBox = qs('#prs-content');
|
||||
const openPrs = (data.pull_requests || []).slice(0, 12);
|
||||
prsBox.innerHTML = (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '" target="_blank">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' +
|
||||
'<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>');
|
||||
const issuesBox = qs('#issues-content');
|
||||
const openIssues = (data.issues || []).filter(i => i.state === 'open').slice(0, 12);
|
||||
issuesBox.innerHTML = (openIssues.length ? openIssues.map(i => '<div style="margin:6px 0;"><a href="' + escAttr(i.url) + '" target="_blank">#' + i.number + ' ' + escapeHtml(i.title) + '</a>' +
|
||||
'<div class="muted">' + (i.labels || []).map(l => '<span class="pill">' + escapeHtml(String(l)) + '</span>').join(' ') + '</div></div>').join('') : '<div class="muted">No open issues.</div>');
|
||||
|
||||
paintDeltas(data.deltas || []);
|
||||
setStatus(data.error ? 'Degraded · ' + data.error : 'Live · updated just now');
|
||||
setClock();
|
||||
} catch (e) {
|
||||
console.error('context failed', e);
|
||||
liveMode = false;
|
||||
setStatus('Unavailable');
|
||||
const prsBox = qs('#prs-content');
|
||||
const openPrs = (data.pull_requests || []).slice(0, 12);
|
||||
prsBox.innerHTML = (openPrs.length ? openPrs.map(p => '<div style="margin:6px 0;"><a href="' + escAttr(p.url) + '" target="_blank">#' + p.number + ' ' + escapeHtml(p.title) + '</a>' +
|
||||
'<div class="muted">' + escapeHtml(p.state) + ' by ' + escapeHtml(String(p.user || '')) + '</div></div>').join('') : '<div class="muted">No PRs.</div>');
|
||||
|
||||
paintDeltas(data.deltas || []);
|
||||
qs('#layout-hint').innerHTML = '<div class="kv"><div class="label">Active view</div><div class="value">' + escapeHtml(data.view || 'dashboard') + '</div><div class="label">Deltas</div><div class="value">' + (data.deltas||[]).length + '</div></div>';
|
||||
renderRepoMix(qs('#repo-mix'), data);
|
||||
setStatus(data.error ? 'Degraded · ' + data.error : 'Live · updated just now');
|
||||
setClock();
|
||||
}
|
||||
|
||||
function handleContextError(e) {
|
||||
console.error('context failed', e);
|
||||
liveMode = false;
|
||||
setStatus(hasContextSnapshot ? 'Update failed · showing last snapshot' : 'Unavailable');
|
||||
if (!hasContextSnapshot) {
|
||||
qs('#context').innerHTML = '<div class="muted">Context unavailable.</div>';
|
||||
qs('#view-hint').textContent = 'Active view unavailable.';
|
||||
qs('#issues-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
|
||||
qs('#prs-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
|
||||
paintDeltas([]);
|
||||
markMyWorkStale();
|
||||
}
|
||||
markMyWorkStale();
|
||||
}
|
||||
|
||||
function paintMyWork(data) {
|
||||
lastMyWork = buildMyWork(data);
|
||||
const counts = countMyWork(lastMyWork);
|
||||
Object.entries(counts).forEach(([filter, count]) => {
|
||||
const element = qs('[data-work-count="' + filter + '"]');
|
||||
if (element) element.textContent = count;
|
||||
});
|
||||
qs('#my-work').removeAttribute('data-stale');
|
||||
qs('#my-work-status').textContent = lastMyWork.length ?
|
||||
summarizeMyWork(lastMyWork) : 'No assigned work or review requests.';
|
||||
|
|
@ -326,15 +347,6 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
}
|
||||
}
|
||||
|
||||
async function tickWidgets() {
|
||||
try {
|
||||
const res = await fetch('api/v1/context', { headers: { Accept: 'application/json' } });
|
||||
const data = await res.json();
|
||||
qs('#layout-hint').innerHTML = '<div class="kv"><div class="label">Active view</div><div class="value">' + escapeHtml(data.view || 'dashboard') + '</div><div class="label">Deltas</div><div class="value">' + (data.deltas||[]).length + '</div></div>';
|
||||
} catch (e) {
|
||||
qs('#layout-hint').innerHTML = '<div class="muted">Mock layout mode</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) { return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
function escAttr(s) { return escapeHtml(s); }
|
||||
|
|
@ -405,26 +417,40 @@ textarea { resize: vertical; min-height: 120px; }
|
|||
document.addEventListener('keydown', (e) => { if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); qs('#cmd-palette').classList.toggle('open'); if(qs('#cmd-palette').classList.contains('open')){ qs('#cmd-input').focus(); renderCommands(''); } } });
|
||||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||
|
||||
const contextPoller = createContextPoller({
|
||||
fetchContext: fetchContextSnapshot,
|
||||
onSnapshot: renderContextSnapshot,
|
||||
onError: handleContextError,
|
||||
isHidden: () => document.hidden,
|
||||
intervalMs: 8000,
|
||||
});
|
||||
function load() { return contextPoller.refresh(); }
|
||||
|
||||
qs('#refresh').addEventListener('click', load);
|
||||
document.querySelectorAll('[data-work-filter]').forEach(button => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
|
||||
button.addEventListener('click', () => {
|
||||
selectedWorkFilter = button.dataset.workFilter;
|
||||
try {
|
||||
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
|
||||
} catch (e) {
|
||||
console.warn('Could not persist My Work filter', e);
|
||||
}
|
||||
document.querySelectorAll('[data-work-filter]').forEach(item =>
|
||||
item.setAttribute('aria-pressed', String(item === button))
|
||||
);
|
||||
renderMyWork();
|
||||
});
|
||||
});
|
||||
load();
|
||||
contextPoller.start();
|
||||
loadEventStream();
|
||||
setInterval(load, 8000);
|
||||
setInterval(loadEventStream, 5000);
|
||||
setInterval(tickWidgets, 2000);
|
||||
tickWidgets();
|
||||
setInterval(() => { if (!document.hidden) loadEventStream(); }, 5000);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
contextPoller.setVisible(!document.hidden);
|
||||
if (!document.hidden) loadEventStream();
|
||||
});
|
||||
|
||||
/* Widgets */
|
||||
updateRepoMix(qs('#repo-mix'), fetch);
|
||||
|
||||
function widgetTick() { const el=qs('#widget-clock'); if(el) el.textContent = fmt(new Date()); }
|
||||
setInterval(widgetTick, 1000);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -41,8 +41,18 @@ function summarizeMyWork(items) {
|
|||
return reviewLabel + ' · ' + assignedLabel;
|
||||
}
|
||||
|
||||
function countMyWork(items) {
|
||||
return {
|
||||
all: items.length,
|
||||
issue: items.filter((item) => item.kind === 'issue').length,
|
||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||
review: items.filter((item) => item.is_review).length,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
module.exports = buildMyWork;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
function renderRepoMix(element, data) {
|
||||
const repos = data.repos || [];
|
||||
const issues = (data.issues || []).filter((issue) => issue.state === 'open');
|
||||
const pullRequests = data.pull_requests || [];
|
||||
element.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + pullRequests.length + '</div></div>';
|
||||
}
|
||||
|
||||
async function updateRepoMix(element, fetchContext = fetch) {
|
||||
try {
|
||||
const response = await fetchContext('api/v1/context', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
const data = await response.json();
|
||||
const repos = data.repos || [];
|
||||
const issues = (data.issues || []).filter((issue) => issue.state === 'open');
|
||||
const pullRequests = data.pull_requests || [];
|
||||
element.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + pullRequests.length + '</div></div>';
|
||||
renderRepoMix(element, await response.json());
|
||||
} catch (error) {
|
||||
element.innerHTML = '<div class="muted">Widget unavailable.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
updateRepoMix.renderRepoMix = renderRepoMix;
|
||||
module.exports = updateRepoMix;
|
||||
}
|
||||
|
|
|
|||
65
tests/test_context_polling.py
Normal file
65
tests/test_context_polling.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
POLLER = Path(__file__).parents[1] / "frontend" / "context-poller.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_context_poller_is_single_flight_and_pauses_until_visible():
|
||||
script = f"""
|
||||
const createContextPoller = require({json.dumps(str(POLLER))});
|
||||
let calls = 0;
|
||||
let hidden = false;
|
||||
let resolveRequest;
|
||||
const timers = [];
|
||||
const poller = createContextPoller({{
|
||||
fetchContext: () => {{
|
||||
calls += 1;
|
||||
if (calls > 1) return Promise.resolve({{ repos: [] }});
|
||||
return new Promise((resolve) => {{ resolveRequest = resolve; }});
|
||||
}},
|
||||
onSnapshot: () => {{}},
|
||||
onError: () => {{}},
|
||||
isHidden: () => hidden,
|
||||
setTimer: (callback) => {{ timers.push(callback); return timers.length; }},
|
||||
clearTimer: () => {{}},
|
||||
intervalMs: 8000,
|
||||
}});
|
||||
|
||||
(async () => {{
|
||||
const first = poller.start();
|
||||
const joined = poller.refresh();
|
||||
const callsWhilePending = calls;
|
||||
resolveRequest({{ repos: [] }});
|
||||
await Promise.all([first, joined]);
|
||||
|
||||
hidden = true;
|
||||
poller.setVisible(false);
|
||||
const scheduled = timers.shift();
|
||||
if (scheduled) scheduled();
|
||||
await Promise.resolve();
|
||||
const callsWhileHidden = calls;
|
||||
|
||||
hidden = false;
|
||||
await poller.setVisible(true);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
callsWhilePending,
|
||||
callsWhileHidden,
|
||||
callsAfterResume: calls,
|
||||
}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"callsWhilePending": 1,
|
||||
"callsWhileHidden": 1,
|
||||
"callsAfterResume": 2,
|
||||
}
|
||||
|
|
@ -17,7 +17,8 @@ def test_dashboard_has_realtime_gitea_event_stream_widget():
|
|||
assert "Gitea event stream" in html
|
||||
assert "id=\"gitea-events\"" in html
|
||||
assert "function paintEventStream" in html
|
||||
assert "setInterval(loadEventStream, 5000)" in html
|
||||
assert "setInterval(() => { if (!document.hidden) loadEventStream(); }, 5000)" in html
|
||||
assert "if (!document.hidden) loadEventStream();" in html
|
||||
assert "event.actor?.login" in html
|
||||
assert "event.repo?.full_name" in html
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,24 @@ process.stdout.write(JSON.stringify({{
|
|||
assert output["summary"] == "1 review · 2 assigned"
|
||||
|
||||
|
||||
def test_my_work_filter_counts_distinguish_prs_from_review_requests():
|
||||
items = [
|
||||
{"kind": "issue", "is_review": False},
|
||||
{"kind": "pull", "is_review": False},
|
||||
{"kind": "pull", "is_review": True},
|
||||
]
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
process.stdout.write(JSON.stringify(buildMyWork.countMyWork({json.dumps(items)})));
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {"all": 3, "issue": 1, "pull": 1, "review": 1}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
|
||||
html = await dashboard()
|
||||
|
|
@ -112,3 +130,16 @@ async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels(
|
|||
assert "buildMyWork(data)" in html
|
||||
assert "markMyWorkStale()" in html
|
||||
assert "filterMyWork(lastMyWork, selectedWorkFilter)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
|
||||
html = await dashboard()
|
||||
|
||||
assert '.work-filters { display:flex; gap:8px; flex-wrap:wrap; }' in html
|
||||
assert 'data-work-count="all"' in html
|
||||
assert 'data-work-count="issue"' in html
|
||||
assert 'data-work-count="pull"' in html
|
||||
assert 'data-work-count="review"' in html
|
||||
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
|
||||
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
|
||||
|
|
|
|||
17
tests/test_shared_context_snapshot.py
Normal file
17
tests/test_shared_context_snapshot.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_one_context_snapshot_updates_every_context_backed_panel():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/context-poller.js"></script>' in html
|
||||
assert "createContextPoller({" in html
|
||||
assert "renderContextSnapshot(data)" in html
|
||||
assert "renderRepoMix(qs('#repo-mix'), data)" in html
|
||||
assert "setInterval(tickWidgets, 2000)" not in html
|
||||
assert "updateRepoMix(qs('#repo-mix'), fetch)" not in html
|
||||
assert "setInterval(load, 8000)" not in html
|
||||
assert "document.addEventListener('visibilitychange'" in html
|
||||
Loading…
Reference in New Issue
Block a user