Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
679639394f | ||
|
|
3b374cbb50 | ||
|
|
ad5e50eb48 | ||
|
|
4b8d0c4504 | ||
|
|
6e3b6804c1 |
No files matched your search
@@ -1,21 +1,41 @@
|
||||
function createAssignAndStart({ available, claim, start, recover, announce }) {
|
||||
function createAssignAndStart({ available, claim, start, queue, recover, announce }) {
|
||||
let request = null;
|
||||
|
||||
function run(item) {
|
||||
function run(item, { alreadyOwned = false, destination = 'start' } = {}) {
|
||||
if (request) return request;
|
||||
if (!available()) {
|
||||
announce('Today is full—remove an item before assigning this issue.');
|
||||
return Promise.resolve('full');
|
||||
}
|
||||
request = Promise.resolve()
|
||||
.then(() => claim(item))
|
||||
.then(() => alreadyOwned ? item : claim(item))
|
||||
.then(confirmed => {
|
||||
const outcome = start(confirmed);
|
||||
if (outcome === 'started') {
|
||||
announce('Assigned, added to Today, and ready to work.');
|
||||
const outcome = destination === 'queue' ? queue(confirmed) : start(confirmed);
|
||||
if (outcome === 'queued') {
|
||||
announce(alreadyOwned ? 'Queued in Today. Keep finding work when ready.' :
|
||||
'Assigned and queued in Today. Keep finding work when ready.');
|
||||
return outcome;
|
||||
}
|
||||
announce('Assigned to you, but Today could not start. The issue is open so you can recover.');
|
||||
if (outcome === 'started') {
|
||||
announce(alreadyOwned ? 'Added to Today and ready to work.' :
|
||||
'Assigned, added to Today, and ready to work.');
|
||||
return outcome;
|
||||
}
|
||||
if (destination === 'queue') {
|
||||
if (outcome === 'sync-unavailable') {
|
||||
announce(alreadyOwned ?
|
||||
'Saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.' :
|
||||
'Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.');
|
||||
} else {
|
||||
announce(alreadyOwned ?
|
||||
'Today could not be queued. The issue is open so you can recover.' :
|
||||
'Assigned to you, but Today could not be queued. The issue is open so you can recover.');
|
||||
}
|
||||
} else {
|
||||
announce(alreadyOwned ?
|
||||
'Today could not start. The issue is open so you can recover.' :
|
||||
'Assigned to you, but Today could not start. The issue is open so you can recover.');
|
||||
}
|
||||
recover(confirmed);
|
||||
return 'recovery';
|
||||
})
|
||||
|
||||
@@ -264,6 +264,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.find-work-card button { width:100%; font-weight:700; }
|
||||
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
|
||||
.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; }
|
||||
.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
@@ -274,6 +275,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
|
||||
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
|
||||
.markdown-content > :first-child { margin-top:0; }
|
||||
|
||||
+71
-3
@@ -757,6 +757,13 @@
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
|
||||
const queueToday = createQueueToday({
|
||||
todayWork,
|
||||
todaySync,
|
||||
refresh: refreshMyWorkView,
|
||||
warm: warmTodayOffline,
|
||||
});
|
||||
|
||||
function acceptClaimedIssue(confirmed) {
|
||||
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
|
||||
lastContextSnapshot.issues = [confirmed].concat((lastContextSnapshot.issues || []).filter(candidate =>
|
||||
@@ -777,6 +784,10 @@
|
||||
refreshMyWorkView();
|
||||
return createAndStart.complete(claimed);
|
||||
},
|
||||
queue: confirmed => {
|
||||
const claimed = acceptClaimedIssue(confirmed);
|
||||
return queueToday(claimed);
|
||||
},
|
||||
recover: confirmed => {
|
||||
const claimed = acceptClaimedIssue(confirmed);
|
||||
taskOverlayHistory.leave();
|
||||
@@ -1902,8 +1913,9 @@
|
||||
'</div><button type="button" data-preview-index="' + index + '" aria-expanded="' + expanded +
|
||||
'" aria-controls="' + detailId + '">' + (expanded ? 'Hide details' : 'View details') + '</button>' +
|
||||
detail + '<div class="find-work-claim-actions"><button type="button" data-claim-index="' + index +
|
||||
'">Assign to me</button><button type="button" data-claim-start-index="' + index +
|
||||
'">Assign & start</button></div></article>';
|
||||
'">Assign</button><button type="button" data-claim-queue-index="' + index +
|
||||
'">Queue Today</button><button type="button" data-claim-start-index="' + index +
|
||||
'">Start now</button></div></article>';
|
||||
}).join('') : '<div class="muted">No unassigned issues are available on this page.</div>';
|
||||
list.querySelectorAll('[data-preview-index]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
@@ -1935,12 +1947,31 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('[data-claim-queue-index]').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = findWorkController.items()[Number(button.dataset.claimQueueIndex)];
|
||||
if (!item) return;
|
||||
const claimButtons = button.closest('.find-work-card')
|
||||
.querySelectorAll('[data-claim-index], [data-claim-queue-index], [data-claim-start-index]');
|
||||
claimButtons.forEach(action => { action.disabled = true; });
|
||||
try {
|
||||
const outcome = await assignAndStart.run(item, { destination:'queue' });
|
||||
if (outcome === 'queued') {
|
||||
(qs('[data-claim-queue-index]') || qs('#close-find-work')).focus();
|
||||
}
|
||||
} catch (error) {
|
||||
qs('#find-work-status').textContent = error.message + ' Nothing was added to Today; retry assignment.';
|
||||
claimButtons.forEach(action => { action.disabled = false; });
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('[data-claim-start-index]').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = findWorkController.items()[Number(button.dataset.claimStartIndex)];
|
||||
if (!item) return;
|
||||
const claimButtons = button.closest('.find-work-card')
|
||||
.querySelectorAll('[data-claim-index], [data-claim-start-index]');
|
||||
.querySelectorAll('[data-claim-index], [data-claim-queue-index], [data-claim-start-index]');
|
||||
claimButtons.forEach(action => { action.disabled = true; });
|
||||
try {
|
||||
await assignAndStart.run(item);
|
||||
@@ -2644,6 +2675,7 @@
|
||||
const sheet = qs('#search-preview');
|
||||
const status = qs('#search-preview-status');
|
||||
const claimButton = qs('#claim-search-result');
|
||||
const startButton = qs('#start-search-result');
|
||||
if (state.status === 'closed') {
|
||||
sheet.classList.remove('open');
|
||||
return;
|
||||
@@ -2651,6 +2683,8 @@
|
||||
sheet.classList.add('open');
|
||||
claimButton.hidden = true;
|
||||
claimButton.disabled = false;
|
||||
startButton.hidden = true;
|
||||
startButton.disabled = false;
|
||||
if (state.status === 'loading') {
|
||||
searchPreviewDetail = null;
|
||||
qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number;
|
||||
@@ -2680,6 +2714,10 @@
|
||||
claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue'));
|
||||
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
|
||||
claimButton.disabled = state.status === 'claiming';
|
||||
startButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
|
||||
(detail.claimable || detail.assigned_to_me));
|
||||
startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start';
|
||||
startButton.disabled = state.status === 'claiming';
|
||||
status.textContent = state.status === 'claiming' ? 'Assigning this issue to you…' :
|
||||
(state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' :
|
||||
(detail.claimable ? 'This issue is open and unassigned.' :
|
||||
@@ -2694,6 +2732,26 @@
|
||||
),
|
||||
onState: renderSearchPreview,
|
||||
});
|
||||
const searchAssignAndStart = createAssignAndStart({
|
||||
available: createAndStart.available,
|
||||
claim: detail => searchPreview.claim(detail),
|
||||
start: confirmed => {
|
||||
const claimed = acceptClaimedIssue(confirmed);
|
||||
taskOverlayHistory.leave();
|
||||
refreshMyWorkView();
|
||||
return createAndStart.complete(claimed);
|
||||
},
|
||||
recover: confirmed => {
|
||||
const claimed = acceptClaimedIssue(confirmed);
|
||||
taskOverlayHistory.leave();
|
||||
refreshMyWorkView();
|
||||
openRoutedWork(claimed, qs('#open-palette'));
|
||||
},
|
||||
announce: message => {
|
||||
qs('#search-preview-status').textContent = message;
|
||||
qs('#my-work-action-status').textContent = message;
|
||||
},
|
||||
});
|
||||
const mobileSearchViewport = createMobileSearchViewport({
|
||||
palette: qs('#cmd-palette'),
|
||||
results: qs('#cmd-results'),
|
||||
@@ -2902,6 +2960,16 @@
|
||||
qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
|
||||
}
|
||||
});
|
||||
qs('#start-search-result').addEventListener('click', async () => {
|
||||
const detail = searchPreviewDetail;
|
||||
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
|
||||
(!detail.claimable && !detail.assigned_to_me)) return;
|
||||
try {
|
||||
await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me });
|
||||
} catch (error) {
|
||||
qs('#search-preview-status').textContent = error.message + ' Retry assignment and start.';
|
||||
}
|
||||
});
|
||||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||
qs('#find-work').addEventListener('click', openFindWorkSheet);
|
||||
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
|
||||
|
||||
+5
-1
@@ -223,7 +223,10 @@
|
||||
<div id="search-preview-meta" class="row"></div>
|
||||
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
|
||||
<div class="search-preview-actions">
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<div class="search-preview-primary-actions">
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||
</div>
|
||||
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -607,6 +610,7 @@
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
<script src="static/create-and-start.js"></script>
|
||||
<script src="static/assign-and-start.js"></script>
|
||||
<script src="static/queue-today.js"></script>
|
||||
<script src="static/pull-sheet.js"></script>
|
||||
<script src="static/review-sheet.js"></script>
|
||||
<script src="static/work-route.js"></script>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
function createQueueToday({ todayWork, todaySync, refresh, warm }) {
|
||||
return function queueToday(issue) {
|
||||
const added = todayWork.add(issue);
|
||||
if (added !== 'added' && added !== 'exists') return added;
|
||||
if (added === 'added' && !todaySync.enqueue('add', todayWork.identity(issue))) {
|
||||
refresh();
|
||||
warm();
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
refresh();
|
||||
if (added === 'added') todaySync.flush();
|
||||
warm();
|
||||
return 'queued';
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createQueueToday;
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v66';
|
||||
const CACHE = 'stackchain-dashboard-shell-v67';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
@@ -41,6 +41,7 @@ const SHELL = [
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
BASE + 'static/assign-and-start.js',
|
||||
BASE + 'static/queue-today.js',
|
||||
BASE + 'static/pull-sheet.js',
|
||||
BASE + 'static/review-sheet.js',
|
||||
BASE + 'static/work-route.js',
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.middleware.gzip import GZipResponder, IdentityResponder
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
|
||||
def _quality(accept_encoding: str, coding: str) -> float:
|
||||
qualities: dict[str, float] = {}
|
||||
for entry in accept_encoding.split(","):
|
||||
parts = [part.strip() for part in entry.split(";")]
|
||||
name = parts[0].lower()
|
||||
if not name:
|
||||
continue
|
||||
quality = 1.0
|
||||
for parameter in parts[1:]:
|
||||
key, separator, value = parameter.partition("=")
|
||||
if separator and key.strip().lower() == "q":
|
||||
try:
|
||||
quality = float(value.strip())
|
||||
except ValueError:
|
||||
quality = 0.0
|
||||
qualities[name] = quality
|
||||
return qualities.get(coding, qualities.get("*", 0.0))
|
||||
|
||||
|
||||
class NegotiatedGZipMiddleware:
|
||||
"""Compress substantial responses only when the client permits gzip."""
|
||||
|
||||
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 9):
|
||||
self.app = app
|
||||
self.minimum_size = minimum_size
|
||||
self.compresslevel = compresslevel
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
accepted = Headers(scope=scope).get("Accept-Encoding", "")
|
||||
if _quality(accepted, "gzip") > 0:
|
||||
responder: ASGIApp = GZipResponder(
|
||||
self.app, self.minimum_size, compresslevel=self.compresslevel
|
||||
)
|
||||
else:
|
||||
responder = IdentityResponder(self.app, self.minimum_size)
|
||||
await responder(scope, receive, send)
|
||||
@@ -19,6 +19,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
|
||||
|
||||
from src import dashboard_auth, gitea_proxy
|
||||
from src.compression import NegotiatedGZipMiddleware
|
||||
from src.gitea_proxy import (
|
||||
activity_events,
|
||||
current_user,
|
||||
@@ -67,6 +68,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
|
||||
app.add_middleware(RequestBodyLimitMiddleware, limit_for=request_body_limit)
|
||||
app.add_middleware(NegotiatedGZipMiddleware, minimum_size=1_024)
|
||||
CONTEXT_TIMEOUT_SECONDS = 5.0
|
||||
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
|
||||
READINESS_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
|
||||
|
||||
ASSIGN_AND_START = Path(__file__).parents[1] / "frontend" / "assign-and-start.js"
|
||||
QUEUE_TODAY = Path(__file__).parents[1] / "frontend" / "queue-today.js"
|
||||
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
@@ -128,17 +130,147 @@ flow.run(issue).catch(error=>{{
|
||||
}
|
||||
|
||||
|
||||
def test_already_owned_search_result_starts_without_claiming():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
const issue={{repository:'stackchain/dashboard',number:413,assigned_to_me:true}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:()=>{{calls.push('claim');return Promise.resolve(issue);}},
|
||||
start:item=>{{calls.push('start:'+item.number);return 'started';}},
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run(issue, {{alreadyOwned:true}}).then(result=>
|
||||
process.stdout.write(JSON.stringify({{result,calls}}))
|
||||
);
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "started",
|
||||
"calls": [
|
||||
"start:413",
|
||||
"announce:Added to Today and ready to work.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_queue_today_claims_once_without_starting_the_active_session():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
let release;
|
||||
const claimResult=new Promise(resolve=>{{release=resolve;}});
|
||||
const issue={{repository:'stackchain/dashboard',number:415}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:item=>{{calls.push('claim:'+item.number);return claimResult;}},
|
||||
start:item=>{{calls.push('start:'+item.number);return 'started';}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
const first=flow.run(issue, {{destination:'queue'}});
|
||||
const second=flow.run(issue, {{destination:'queue'}});
|
||||
release({{...issue,assignees:['timmy']}});
|
||||
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{
|
||||
calls,results,same:first===second
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": [
|
||||
"claim:415",
|
||||
"queue:415",
|
||||
"announce:Assigned and queued in Today. Keep finding work when ready.",
|
||||
],
|
||||
"results": ["queued", "queued"],
|
||||
"same": True,
|
||||
}
|
||||
|
||||
|
||||
def test_queue_today_persists_syncs_and_warms_without_a_session_side_effect():
|
||||
script = f"""
|
||||
const fs=require('fs');
|
||||
if (!fs.existsSync({json.dumps(str(QUEUE_TODAY))})) {{
|
||||
process.stdout.write(JSON.stringify({{available:false}}));
|
||||
}} else {{
|
||||
const createQueueToday=require({json.dumps(str(QUEUE_TODAY))});
|
||||
const calls=[];
|
||||
const issue={{kind:'issue',repository:'stackchain/dashboard',number:415}};
|
||||
const queue=createQueueToday({{
|
||||
todayWork:{{identity:()=> 'issue:stackchain/dashboard:415:',add:()=>{{calls.push('add');return 'added';}}}},
|
||||
todaySync:{{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}},flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'),
|
||||
warm:()=>calls.push('warm'),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{available:true,result:queue(issue),calls}}));
|
||||
}}
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"available": True,
|
||||
"result": "queued",
|
||||
"calls": [
|
||||
"add",
|
||||
"add:issue:stackchain/dashboard:415:",
|
||||
"refresh",
|
||||
"flush",
|
||||
"warm",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_queue_today_reports_assignment_when_local_planning_needs_recovery():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
const issue={{repository:'stackchain/dashboard',number:415,assignees:['timmy']}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:()=>Promise.resolve(issue),
|
||||
start:()=> 'started',
|
||||
queue:()=> 'sync-unavailable',
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run(issue, {{destination:'queue'}}).then(result=>
|
||||
process.stdout.write(JSON.stringify({{result,calls}}))
|
||||
);
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "recovery",
|
||||
"calls": [
|
||||
"announce:Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.",
|
||||
"recover:415",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_find_work_renders_phone_safe_assign_and_start_and_wires_offline_shell():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert "data-claim-start-index" in dashboard
|
||||
assert ">Assign & start</button>" in dashboard
|
||||
assert "data-claim-queue-index" in dashboard
|
||||
assert ">Start now</button>" in dashboard
|
||||
assert ">Queue Today</button>" in dashboard
|
||||
assert "const assignAndStart = createAssignAndStart({" in dashboard
|
||||
assert "assignAndStart.run(item)" in dashboard
|
||||
assert "assignAndStart.run(item, { destination:'queue' })" in dashboard
|
||||
assert "queue: confirmed =>" in dashboard
|
||||
assert "const queueToday = createQueueToday({" in dashboard
|
||||
assert "return queueToday(claimed)" in dashboard
|
||||
assert "createAndStart.available" in dashboard
|
||||
assert "createAndStart.complete" in dashboard
|
||||
assert ".find-work-claim-actions" in css
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
||||
assert ".find-work-claim-actions [data-claim-start-index]" in css
|
||||
assert "grid-column:1 / -1" in css
|
||||
assert "BASE + 'static/assign-and-start.js'" in worker
|
||||
assert "BASE + 'static/queue-today.js'" in worker
|
||||
assert '<script src="static/queue-today.js"></script>' in html
|
||||
@@ -243,6 +243,18 @@ def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
|
||||
assert "openPreviewIssueInMyWork" in html
|
||||
|
||||
|
||||
def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="start-search-result"' in html
|
||||
assert "startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'" in html
|
||||
assert "const searchAssignAndStart = createAssignAndStart({" in html
|
||||
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
|
||||
assert ".search-preview-primary-actions" in css
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
||||
|
||||
|
||||
def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners():
|
||||
script = f"""
|
||||
const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))});
|
||||
|
||||
@@ -3,8 +3,10 @@ import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.frontend_bundle import build_frontend
|
||||
from src.views import dashboard, runtime_bundle, service_worker
|
||||
|
||||
@@ -37,6 +39,62 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
assert changed.runtime_bytes != first.runtime_bytes
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
f"/{main.FRONTEND_BUILD.runtime_name}",
|
||||
headers={"Accept-Encoding": "gzip"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
assert response.headers["vary"] == "Accept-Encoding"
|
||||
assert int(response.headers["content-length"]) <= 100 * 1024
|
||||
assert response.content == main.FRONTEND_BUILD.runtime_bytes
|
||||
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("accept_encoding", ["identity", "gzip;q=0, identity;q=1"])
|
||||
async def test_runtime_stays_unencoded_when_the_client_declines_gzip(accept_encoding):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
f"/{main.FRONTEND_BUILD.runtime_name}",
|
||||
headers={"Accept-Encoding": accept_encoding},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "content-encoding" not in response.headers
|
||||
assert int(response.headers["content-length"]) == len(main.FRONTEND_BUILD.runtime_bytes)
|
||||
assert response.content == main.FRONTEND_BUILD.runtime_bytes
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_shell_is_compressed_without_losing_response_policies():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
page = await client.get("/", headers={"Accept-Encoding": "gzip"})
|
||||
stylesheet = await client.get(
|
||||
"/static/dashboard.css", headers={"Accept-Encoding": "gzip"}
|
||||
)
|
||||
health = await client.get("/healthz", headers={"Accept-Encoding": "gzip"})
|
||||
|
||||
assert page.headers["content-encoding"] == "gzip"
|
||||
assert page.headers["cache-control"] == "no-cache"
|
||||
assert page.headers["content-security-policy"]
|
||||
assert main.FRONTEND_BUILD.runtime_name in page.text
|
||||
assert stylesheet.headers["content-encoding"] == "gzip"
|
||||
assert "mobile-task-dock" in stylesheet.text
|
||||
assert health.json() == {"service": "stackchain-dashboard", "status": "ok"}
|
||||
assert "content-encoding" not in health.headers
|
||||
|
||||
|
||||
def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules():
|
||||
build = build_frontend(FRONTEND)
|
||||
|
||||
|
||||
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v66" in source
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -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 pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v66" in worker
|
||||
assert "stackchain-dashboard-shell-v67" 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]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v66" in worker
|
||||
assert "stackchain-dashboard-shell-v67" in worker
|
||||
@@ -168,6 +168,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v66" in source
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -121,7 +121,7 @@ async function dispatchNotificationClick(route) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v66" in source
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -130,7 +130,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v66" in source
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -138,14 +138,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v66" in source
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/later-picker.js'" in source
|
||||
|
||||
|
||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v66" in source
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -154,21 +154,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v66" in source
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v66" in source
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v66" in source
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -331,6 +331,13 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
assert "batch: withSessionCsrf" in source
|
||||
|
||||
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -378,6 +385,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/create-issue-sheet.js",
|
||||
"/dashboard/static/create-and-start.js",
|
||||
"/dashboard/static/assign-and-start.js",
|
||||
"/dashboard/static/queue-today.js",
|
||||
"/dashboard/static/pull-sheet.js",
|
||||
"/dashboard/static/review-sheet.js",
|
||||
"/dashboard/static/work-route.js",
|
||||
|
||||
@@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v66" in source
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user