feat: retire closed issues during Following review (Closes #1305)
This commit is contained in:
parent
fdf38a72ac
commit
c4bb7a9be1
|
|
@ -113,8 +113,11 @@ including work already assigned to you or a teammate. This completes the Search
|
||||||
flow without changing ownership or scheduling work. Following is a read-first,
|
flow without changing ownership or scheduling work. Following is a read-first,
|
||||||
account-scoped collection: it is encrypted at rest, revisioned, bounded to 50 canonical issues,
|
account-scoped collection: it is encrypted at rest, revisioned, bounded to 50 canonical issues,
|
||||||
and synchronized across signed-in devices. Opening a row reuses Search Preview; confirmed
|
and synchronized across signed-in devices. Opening a row reuses Search Preview; confirmed
|
||||||
**Stop watching** removes it, while failed or unconfirmed Gitea mutations leave the collection
|
**Stop watching** removes an open item. When watched work closes, the sequential review exposes
|
||||||
unchanged. Following counts never influence the recommended Work queue. Set
|
**Stop watching & next** so the completed item can be retired without leaving the preview; the next
|
||||||
|
captured change opens immediately, and retiring the final item completes the Following phase. Failed
|
||||||
|
or unconfirmed Gitea mutations leave the collection and current review position unchanged. Following
|
||||||
|
counts never influence the recommended Work queue. Set
|
||||||
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.
|
`STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`.
|
||||||
|
|
||||||
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
|
Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged.
|
||||||
|
|
|
||||||
|
|
@ -5774,6 +5774,7 @@
|
||||||
if (!followingQueue.session()) taskOverlayHistory.update({preview:item});
|
if (!followingQueue.session()) taskOverlayHistory.update({preview:item});
|
||||||
},
|
},
|
||||||
onOpened:item => followingQueue.previewLoaded(item),
|
onOpened:item => followingQueue.previewLoaded(item),
|
||||||
|
afterUnwatch:item => followingQueue.retire(item),
|
||||||
navigationRoot:document,
|
navigationRoot:document,
|
||||||
onState: renderSearchPreview,
|
onState: renderSearchPreview,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@
|
||||||
async function open(index) {
|
async function open(index) {
|
||||||
const item = snapshot.items[Number(index)];
|
const item = snapshot.items[Number(index)];
|
||||||
if (!item) return false;
|
if (!item) return false;
|
||||||
await options.onOpen?.({...item, kind:'issue'});
|
await options.onOpen?.({...item, kind:'issue', following:true});
|
||||||
await acknowledge(item);
|
await acknowledge(item);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +71,7 @@
|
||||||
async function startReview() {
|
async function startReview() {
|
||||||
const items = snapshot.items
|
const items = snapshot.items
|
||||||
.filter(item => item.has_unseen_change === true)
|
.filter(item => item.has_unseen_change === true)
|
||||||
.map(item => ({...item, kind:'issue'}));
|
.map(item => ({...item, kind:'issue', following:true}));
|
||||||
if (!items.length) return false;
|
if (!items.length) return false;
|
||||||
review = {items, more:false, active:true, acknowledged:new Set()};
|
review = {items, more:false, active:true, acknowledged:new Set()};
|
||||||
await open(snapshot.items.indexOf(snapshot.items.find(item =>
|
await open(snapshot.items.indexOf(snapshot.items.find(item =>
|
||||||
|
|
@ -80,8 +80,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishReview() {
|
function finishReview() {
|
||||||
const completed = Boolean(review?.active &&
|
const completed = Boolean(review?.completed || (review?.active &&
|
||||||
!snapshot.items.some(item => item.has_unseen_change === true));
|
!snapshot.items.some(item => item.has_unseen_change === true)));
|
||||||
if (review) review.active = false;
|
if (review) review.active = false;
|
||||||
if (completed && !review.completed) {
|
if (completed && !review.completed) {
|
||||||
review.completed = true;
|
review.completed = true;
|
||||||
|
|
@ -91,8 +91,30 @@
|
||||||
return completed;
|
return completed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function retire(item) {
|
||||||
|
const same = candidate => candidate.repository === item?.repository &&
|
||||||
|
Number(candidate.number) === Number(item?.number);
|
||||||
|
const index = review?.active ? review.items.findIndex(same) : -1;
|
||||||
|
snapshot.items = snapshot.items.filter(candidate => !same(candidate));
|
||||||
|
if (index < 0) {
|
||||||
|
publish('ready');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
review.items.splice(index, 1);
|
||||||
|
const next = review.items[index] || null;
|
||||||
|
if (!next) {
|
||||||
|
review.active = false;
|
||||||
|
if (!review.completed) {
|
||||||
|
review.completed = true;
|
||||||
|
options.onReviewComplete?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publish('ready');
|
||||||
|
return next ? {...next} : null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
load, open, startReview, previewLoaded:acknowledge, finishReview,
|
load, open, startReview, previewLoaded:acknowledge, finishReview, retire,
|
||||||
session:() => review?.active ? {items:[...review.items], more:false} : null,
|
session:() => review?.active ? {items:[...review.items], more:false} : null,
|
||||||
items:() => snapshot.items.map(item => ({...item})),
|
items:() => snapshot.items.map(item => ({...item})),
|
||||||
count:() => snapshot.items.length,
|
count:() => snapshot.items.length,
|
||||||
|
|
@ -179,6 +201,7 @@
|
||||||
},
|
},
|
||||||
session:feature.session,
|
session:feature.session,
|
||||||
previewLoaded:feature.previewLoaded,
|
previewLoaded:feature.previewLoaded,
|
||||||
|
retire:feature.retire,
|
||||||
returnToFollowing() {
|
returnToFollowing() {
|
||||||
const completed = feature.finishReview();
|
const completed = feature.finishReview();
|
||||||
if (completed) return 'completed-following';
|
if (completed) return 'completed-following';
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,12 @@
|
||||||
'/comments?kind=' + encodeURIComponent(item.kind);
|
'/comments?kind=' + encodeURIComponent(item.kind);
|
||||||
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') +
|
||||||
'/subscription?kind=' + encodeURIComponent(item.kind);
|
'/subscription?kind=' + encodeURIComponent(item.kind);
|
||||||
root.searchPreviewSubscriptionOptions = fetchJson => ({
|
root.searchPreviewSubscriptionOptions = fetchJson => {
|
||||||
|
const options = {
|
||||||
load:async detail => {
|
load:async detail => {
|
||||||
|
if (detail.kind === 'issue' && detail.state === 'closed' && detail.following === true) {
|
||||||
|
return {...detail, watching:true};
|
||||||
|
}
|
||||||
if (!(detail.kind === 'issue' && detail.state === 'open')) return detail;
|
if (!(detail.kind === 'issue' && detail.state === 'open')) return detail;
|
||||||
const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}});
|
const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}});
|
||||||
return {...detail, watching:result.watching === true};
|
return {...detail, watching:result.watching === true};
|
||||||
|
|
@ -43,7 +47,12 @@
|
||||||
watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
|
watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), {
|
||||||
method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
|
method:watching ? 'PUT' : 'DELETE', headers:{Accept:'application/json'},
|
||||||
}),
|
}),
|
||||||
});
|
};
|
||||||
|
options.preview = async item => options.load({...item, ...await fetchJson(root.searchPreviewPath(item), {
|
||||||
|
headers:{Accept:'application/json'},
|
||||||
|
})});
|
||||||
|
return options;
|
||||||
|
};
|
||||||
root.searchPreviewWatchStatus = state => ({
|
root.searchPreviewWatchStatus = state => ({
|
||||||
watching:'Starting watch…', unwatching:'Stopping watch…',
|
watching:'Starting watch…', unwatching:'Stopping watch…',
|
||||||
watched:'Watching · available in Following. Future activity will appear in Updates.',
|
watched:'Watching · available in Following. Future activity will appear in Updates.',
|
||||||
|
|
@ -52,8 +61,11 @@
|
||||||
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
|
'watch-error':(state.error?.message || 'Watch status was not changed.') + ' Retry.',
|
||||||
})[state.status] || '';
|
})[state.status] || '';
|
||||||
root.renderSearchPreviewWatch = (detail, state, button) => {
|
root.renderSearchPreviewWatch = (detail, state, button) => {
|
||||||
button.hidden = !(detail.kind === 'issue' && detail.state === 'open');
|
const retiring = detail.kind === 'issue' && detail.state === 'closed' &&
|
||||||
button.textContent = detail.watching ? 'Stop watching' : 'Watch issue';
|
detail.following === true && detail.watching === true;
|
||||||
|
button.hidden = !(detail.kind === 'issue' && detail.state === 'open') && !retiring;
|
||||||
|
button.textContent = retiring ? 'Stop watching & next' :
|
||||||
|
(detail.watching ? 'Stop watching' : 'Watch issue');
|
||||||
button.disabled = state.status === 'watching' || state.status === 'unwatching';
|
button.disabled = state.status === 'watching' || state.status === 'unwatching';
|
||||||
};
|
};
|
||||||
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
|
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
|
||||||
|
|
@ -120,7 +132,7 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
return function createSearchPreview({ fetchJson, fetchConversation, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) {
|
return function createSearchPreview({ fetchJson, fetchConversation, mutate, watch, share, postReply, queueReply, prepareReply, afterReply, afterUnwatch, hasAttachments, clearAttachments, storage, createOperationId, session, getSession, loadMore, onNavigate, onOpened, navigationRoot, onState }) {
|
||||||
if (Array.isArray(session)) {
|
if (Array.isArray(session)) {
|
||||||
getSession = session[0];
|
getSession = session[0];
|
||||||
loadMore = () => session[1].loadMore();
|
loadMore = () => session[1].loadMore();
|
||||||
|
|
@ -385,11 +397,18 @@
|
||||||
}
|
}
|
||||||
const detail = current;
|
const detail = current;
|
||||||
publish({ status:watching ? 'watching' : 'unwatching', item:current, detail });
|
publish({ status:watching ? 'watching' : 'unwatching', item:current, detail });
|
||||||
watchRequest = watch(detail, watching).then(result => {
|
watchRequest = watch(detail, watching).then(async result => {
|
||||||
current = { ...current, watching:result?.watching === true };
|
current = { ...current, watching:result?.watching === true };
|
||||||
const status = result?.following_synced === false
|
const status = result?.following_synced === false
|
||||||
? 'watch-partial' : (watching ? 'watched' : 'unwatched');
|
? 'watch-partial' : (watching ? 'watched' : 'unwatched');
|
||||||
publish({ status, item:current, detail:current, result });
|
publish({ status, item:current, detail:current, result });
|
||||||
|
if (!watching && result?.watching === false && typeof afterUnwatch === 'function') {
|
||||||
|
const next = await afterUnwatch({...current});
|
||||||
|
if (next) {
|
||||||
|
onNavigate?.(next);
|
||||||
|
await api.open(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
publish({ status:'watch-error', item:current, detail, error });
|
publish({ status:'watch-error', item:current, detail, error });
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/private-data-registry.js');
|
importScripts(BASE + 'static/private-data-registry.js');
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v133';
|
const CACHE = 'stackchain-dashboard-shell-v134';
|
||||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
|
|
|
||||||
|
|
@ -3738,7 +3738,7 @@ async def global_search_preview(
|
||||||
|
|
||||||
|
|
||||||
async def _search_preview_subscription_target(
|
async def _search_preview_subscription_target(
|
||||||
owner: str, repo: str, number: int, kind: str
|
owner: str, repo: str, number: int, kind: str, *, allow_closed: bool = False
|
||||||
) -> tuple[str, dict]:
|
) -> tuple[str, dict]:
|
||||||
repository = f"{owner}/{repo}"
|
repository = f"{owner}/{repo}"
|
||||||
preview = await gitea_proxy.work_preview(repository, kind, number)
|
preview = await gitea_proxy.work_preview(repository, kind, number)
|
||||||
|
|
@ -3747,7 +3747,7 @@ async def _search_preview_subscription_target(
|
||||||
or preview.get("repository") != repository
|
or preview.get("repository") != repository
|
||||||
or preview.get("kind") != "issue"
|
or preview.get("kind") != "issue"
|
||||||
or preview.get("number") != number
|
or preview.get("number") != number
|
||||||
or preview.get("state") != "open"
|
or preview.get("state") not in ({"open", "closed"} if allow_closed else {"open"})
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=404, detail="Watchable search result not found")
|
raise HTTPException(status_code=404, detail="Watchable search result not found")
|
||||||
return repository, preview
|
return repository, preview
|
||||||
|
|
@ -3790,7 +3790,9 @@ async def mutate_global_search_preview_subscription(
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
watching = request.method == "PUT"
|
watching = request.method == "PUT"
|
||||||
try:
|
try:
|
||||||
repository, preview = await _search_preview_subscription_target(owner, repo, number, kind)
|
repository, preview = await _search_preview_subscription_target(
|
||||||
|
owner, repo, number, kind, allow_closed=not watching
|
||||||
|
)
|
||||||
login = await _confirmed_login()
|
login = await _confirmed_login()
|
||||||
store = _following_store()
|
store = _following_store()
|
||||||
following_item = {
|
following_item = {
|
||||||
|
|
|
||||||
|
|
@ -901,7 +901,7 @@ def test_mobile_search_preview_exposes_touch_safe_watch_action():
|
||||||
assert ".search-preview-actions button" in css and "min-height:44px" in css
|
assert ".search-preview-actions button" in css and "min-height:44px" in css
|
||||||
|
|
||||||
|
|
||||||
def test_search_preview_watch_is_available_for_every_open_issue_but_not_pulls_or_closed_issues():
|
def test_search_preview_watch_is_available_for_open_issues_and_closed_following_retirement():
|
||||||
script = f"""
|
script = f"""
|
||||||
require({json.dumps(str(SEARCH_PREVIEW))});
|
require({json.dumps(str(SEARCH_PREVIEW))});
|
||||||
(async () => {{
|
(async () => {{
|
||||||
|
|
@ -925,7 +925,10 @@ for (const detail of [
|
||||||
globalThis.renderSearchPreviewWatch(detail, {{status:'ready'}}, candidate);
|
globalThis.renderSearchPreviewWatch(detail, {{status:'ready'}}, candidate);
|
||||||
excluded.push(candidate.hidden);
|
excluded.push(candidate.hidden);
|
||||||
}}
|
}}
|
||||||
process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,excluded}}));
|
const closedFollowing = await options.load({{...assigned,state:'closed',following:true}});
|
||||||
|
const retire = {{hidden:true,textContent:'',disabled:false}};
|
||||||
|
globalThis.renderSearchPreviewWatch(closedFollowing, {{status:'ready'}}, retire);
|
||||||
|
process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,excluded,closedFollowing,retire}}));
|
||||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
@ -942,6 +945,98 @@ process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,e
|
||||||
"disabled": False,
|
"disabled": False,
|
||||||
}
|
}
|
||||||
assert payload["excluded"] == [True, True]
|
assert payload["excluded"] == [True, True]
|
||||||
|
assert payload["closedFollowing"]["watching"] is True
|
||||||
|
assert payload["retire"] == {
|
||||||
|
"hidden": False,
|
||||||
|
"textContent": "Stop watching & next",
|
||||||
|
"disabled": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_closed_following_unwatch_is_single_flight_and_advances_only_after_confirmation():
|
||||||
|
script = f"""
|
||||||
|
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||||
|
(async () => {{
|
||||||
|
let finish;
|
||||||
|
const states=[]; const retired=[]; const opened=[]; let calls=0;
|
||||||
|
const next={{repository:'stackchain/web',number:9,kind:'issue',state:'open'}};
|
||||||
|
const preview=createSearchPreview({{
|
||||||
|
fetchJson:item => Promise.resolve(item), mutate:()=>Promise.resolve(),
|
||||||
|
watch:() => {{ calls += 1; return new Promise(resolve => finish=resolve); }},
|
||||||
|
afterUnwatch:item => {{ retired.push(item.number); return next; }},
|
||||||
|
onNavigate:item => opened.push(item.number), onState:state => states.push(state),
|
||||||
|
}});
|
||||||
|
await preview.open({{repository:'stackchain/api',number:42,kind:'issue',state:'closed',following:true,watching:true}});
|
||||||
|
const first=preview.setWatching(false); const second=preview.setWatching(false);
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
const before={{calls,retired:[...retired],opened:[...opened]}};
|
||||||
|
finish({{watching:false,following_synced:true}});
|
||||||
|
await Promise.all([first,second]);
|
||||||
|
process.stdout.write(JSON.stringify({{before,calls,same:first===second,retired,opened,last:states.at(-1)}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
assert payload["before"] == {"calls": 1, "retired": [], "opened": []}
|
||||||
|
assert payload["same"] is True
|
||||||
|
assert payload["retired"] == [42]
|
||||||
|
assert payload["opened"] == [9]
|
||||||
|
assert payload["last"]["detail"]["number"] == 9
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_subscription_preview_hydrates_detail_before_watch_state():
|
||||||
|
script = f"""
|
||||||
|
require({json.dumps(str(SEARCH_PREVIEW))});
|
||||||
|
(async () => {{
|
||||||
|
const calls=[];
|
||||||
|
const options=globalThis.searchPreviewSubscriptionOptions(async path => {{
|
||||||
|
calls.push(path);
|
||||||
|
if (path.includes('/subscription')) return {{watching:true}};
|
||||||
|
return {{repository:'stackchain/api',number:42,kind:'issue',state:'open',title:'Hydrated'}};
|
||||||
|
}});
|
||||||
|
const detail=await options.preview({{repository:'stackchain/api',number:42,kind:'issue'}});
|
||||||
|
process.stdout.write(JSON.stringify({{calls,detail}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert json.loads(result.stdout) == {
|
||||||
|
"calls": [
|
||||||
|
"api/v1/repos/stackchain/api/issues/42/preview?kind=issue",
|
||||||
|
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
|
||||||
|
],
|
||||||
|
"detail": {
|
||||||
|
"repository": "stackchain/api", "number": 42, "kind": "issue",
|
||||||
|
"state": "open", "title": "Hydrated", "watching": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_closed_following_preview_preserves_origin_and_skips_status_lookup():
|
||||||
|
script = f"""
|
||||||
|
require({json.dumps(str(SEARCH_PREVIEW))});
|
||||||
|
(async () => {{
|
||||||
|
const calls=[];
|
||||||
|
const options=globalThis.searchPreviewSubscriptionOptions(async path => {{
|
||||||
|
calls.push(path);
|
||||||
|
return {{repository:'stackchain/api',number:42,kind:'issue',state:'closed',title:'Finished'}};
|
||||||
|
}});
|
||||||
|
const detail=await options.preview({{repository:'stackchain/api',number:42,kind:'issue',following:true}});
|
||||||
|
process.stdout.write(JSON.stringify({{calls,detail}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
assert payload["calls"] == [
|
||||||
|
"api/v1/repos/stackchain/api/issues/42/preview?kind=issue"
|
||||||
|
]
|
||||||
|
assert payload["detail"]["following"] is True
|
||||||
|
assert payload["detail"]["watching"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_search_preview_shares_canonical_url_without_closing_the_preview():
|
def test_search_preview_shares_canonical_url_without_closing_the_preview():
|
||||||
|
|
|
||||||
|
|
@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||||
assert '.update-reply-actions button { min-height:44px;' in html
|
assert '.update-reply-actions button { min-height:44px;' in html
|
||||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
assert "stackchain-dashboard-shell-v133" in worker
|
assert "stackchain-dashboard-shell-v134" in worker
|
||||||
|
|
|
||||||
|
|
@ -249,3 +249,49 @@ async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["items"][0]["has_unseen_change"] is False
|
assert response.json()["items"][0]["has_unseen_change"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_closed_following_issue_can_be_unwatched_but_not_newly_watched(monkeypatch, tmp_path):
|
||||||
|
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32)
|
||||||
|
closed = {
|
||||||
|
"repository": "stackchain/api", "number": 42, "title": "Finished work",
|
||||||
|
"state": "closed", "updated_at": "2026-08-23T04:00:00Z",
|
||||||
|
"url": "https://forge.example/stackchain/api/issues/42",
|
||||||
|
}
|
||||||
|
store.set_watching("timmy", closed, True)
|
||||||
|
mutations = []
|
||||||
|
|
||||||
|
async def preview(repository, kind, number):
|
||||||
|
return {**closed, "kind": "issue"}
|
||||||
|
|
||||||
|
async def set_subscription(repository, number, watching):
|
||||||
|
mutations.append((repository, number, watching))
|
||||||
|
return {"watching": watching}
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"login": "timmy"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "_following_store", lambda: store)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
|
||||||
|
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription)
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
watch = await client.put(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||||
|
)
|
||||||
|
unwatch = await client.delete(
|
||||||
|
"/api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert watch.status_code == 404
|
||||||
|
assert unwatch.status_code == 200
|
||||||
|
assert unwatch.json() == {
|
||||||
|
"watching": False,
|
||||||
|
"following_synced": True,
|
||||||
|
"following_revision": 2,
|
||||||
|
"following_count": 0,
|
||||||
|
}
|
||||||
|
assert mutations == [("stackchain/api", 42, False)]
|
||||||
|
assert store.get("timmy")["items"] == []
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ process.stdout.write(JSON.stringify(state));
|
||||||
"url": "https://forge.example/issue/42",
|
"url": "https://forge.example/issue/42",
|
||||||
"has_unseen_change": False,
|
"has_unseen_change": False,
|
||||||
"kind": "issue",
|
"kind": "issue",
|
||||||
|
"following": True,
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -133,6 +134,7 @@ const feature = createFollowing({{
|
||||||
assert result["started"] is True
|
assert result["started"] is True
|
||||||
assert [item["number"] for item in result["session"]["items"]] == [42, 9]
|
assert [item["number"] for item in result["session"]["items"]] == [42, 9]
|
||||||
assert all(item["kind"] == "issue" for item in result["session"]["items"])
|
assert all(item["kind"] == "issue" for item in result["session"]["items"])
|
||||||
|
assert all(item["following"] is True for item in result["session"]["items"])
|
||||||
assert result["session"]["more"] is False
|
assert result["session"]["more"] is False
|
||||||
assert result["state"] == {"opened": [42], "acknowledged": [42]}
|
assert result["state"] == {"opened": [42], "acknowledged": [42]}
|
||||||
|
|
||||||
|
|
@ -196,6 +198,46 @@ const feature = createFollowing({{
|
||||||
).stdout) == {"beforeFinish": [], "completed": ["following"]}
|
).stdout) == {"beforeFinish": [], "completed": ["following"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_following_retirement_removes_closed_item_and_keeps_review_moving():
|
||||||
|
script = f"""
|
||||||
|
const createFollowing = require({json.dumps(str(MODULE))});
|
||||||
|
const state = {{renders:[], counts:[], completed:[]}};
|
||||||
|
const items=[
|
||||||
|
{{repository:'stackchain/api',number:42,title:'Closed first',state:'closed',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}},
|
||||||
|
{{repository:'stackchain/web',number:9,title:'Closed second',state:'closed',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}}
|
||||||
|
];
|
||||||
|
const feature = createFollowing({{
|
||||||
|
fetchJson:async () => ({{revision:7,items}}),
|
||||||
|
render:value => state.renders.push(value), onCount:value => state.counts.push(value),
|
||||||
|
onOpen:async () => {{}}, onAcknowledge:async () => {{}},
|
||||||
|
onReviewComplete:() => state.completed.push('following'),
|
||||||
|
}});
|
||||||
|
(async () => {{
|
||||||
|
await feature.load();
|
||||||
|
await feature.startReview();
|
||||||
|
const first=feature.session().items[0];
|
||||||
|
const next=feature.retire(first);
|
||||||
|
await feature.previewLoaded(next);
|
||||||
|
const final=feature.retire(next);
|
||||||
|
const handoff=feature.finishReview();
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
next:next.number, final, handoff, items:feature.items(), session:feature.session(), state
|
||||||
|
}}));
|
||||||
|
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||||
|
"""
|
||||||
|
result = json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], text=True, capture_output=True, check=True
|
||||||
|
).stdout)
|
||||||
|
|
||||||
|
assert result["next"] == 9
|
||||||
|
assert result["final"] is None
|
||||||
|
assert result["handoff"] is True
|
||||||
|
assert result["items"] == []
|
||||||
|
assert result["session"] is None
|
||||||
|
assert result["state"]["counts"][-1] == 0
|
||||||
|
assert result["state"]["completed"] == ["following"]
|
||||||
|
|
||||||
|
|
||||||
def test_following_review_never_clears_activity_newer_than_the_loaded_preview():
|
def test_following_review_never_clears_activity_newer_than_the_loaded_preview():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createFollowing = require({json.dumps(str(MODULE))});
|
const createFollowing = require({json.dumps(str(MODULE))});
|
||||||
|
|
@ -250,11 +292,12 @@ def test_following_review_controls_are_wired_into_the_phone_preview_flow():
|
||||||
assert "query('#review-following').addEventListener('click'" in following
|
assert "query('#review-following').addEventListener('click'" in following
|
||||||
assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard
|
assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard
|
||||||
assert "onOpened:item => followingQueue.previewLoaded(item)" in dashboard
|
assert "onOpened:item => followingQueue.previewLoaded(item)" in dashboard
|
||||||
|
assert "afterUnwatch:item => followingQueue.retire(item)" in dashboard
|
||||||
assert "followingQueue.returnToFollowing()" in dashboard
|
assert "followingQueue.returnToFollowing()" in dashboard
|
||||||
assert "'Back to Following'" in dashboard
|
assert "'Back to Following'" in dashboard
|
||||||
assert "if (searchPreviewReturnKind === 'following')" in dashboard
|
assert "if (searchPreviewReturnKind === 'following')" in dashboard
|
||||||
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
|
assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard
|
||||||
assert "stackchain-dashboard-shell-v133" in service_worker
|
assert "stackchain-dashboard-shell-v134" in service_worker
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
||||||
|
|
|
||||||
|
|
@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
assert "BASE + 'static/later-sync.js'" in source
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -256,4 +256,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-v133" in worker
|
assert "stackchain-dashboard-shell-v134" in worker
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ 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-v133" in worker
|
assert "stackchain-dashboard-shell-v134" in worker
|
||||||
|
|
||||||
|
|
||||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||||
|
|
|
||||||
|
|
@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||||
assert "controller.recoverPermission('deadline')" in dashboard
|
assert "controller.recoverPermission('deadline')" in dashboard
|
||||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||||
assert "stackchain-dashboard-shell-v133" in worker
|
assert "stackchain-dashboard-shell-v134" in worker
|
||||||
assert ".device-setup-panel" in css
|
assert ".device-setup-panel" in css
|
||||||
assert ".device-readiness-card" in css
|
assert ".device-readiness-card" in css
|
||||||
assert "overflow-x:hidden" in css
|
assert "overflow-x:hidden" in css
|
||||||
|
|
|
||||||
|
|
@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights(
|
||||||
def test_mobile_insights_rolls_into_the_offline_shell():
|
def test_mobile_insights_rolls_into_the_offline_shell():
|
||||||
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
worker = (CONTROLLER.parent / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in worker
|
assert "stackchain-dashboard-shell-v134" in worker
|
||||||
assert "BASE + 'static/mobile-insights.js'" in worker
|
assert "BASE + 'static/mobile-insights.js'" in worker
|
||||||
|
|
|
||||||
|
|
@ -414,7 +414,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile
|
||||||
assert ".mobile-start-day-finish { min-height:44px;" in html
|
assert ".mobile-start-day-finish { min-height:44px;" in html
|
||||||
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
assert "max-width:100%; overflow-wrap:anywhere;" in html
|
||||||
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
assert "BASE + 'static/mobile-start-day.js'" in service_worker
|
||||||
assert "stackchain-dashboard-shell-v133" in service_worker
|
assert "stackchain-dashboard-shell-v134" in service_worker
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
|
||||||
|
|
@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
|
||||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
source = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
assert "BASE + 'static/plan-today.js'" in source
|
assert "BASE + 'static/plan-today.js'" in source
|
||||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ async function dispatchPush(payload) {{
|
||||||
def test_week_unplan_undo_rolls_the_offline_shell():
|
def test_week_unplan_undo_rolls_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
assert "BASE + 'static/week-plan.js'" in source
|
assert "BASE + 'static/week-plan.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
|
|
||||||
|
|
@ -194,20 +194,20 @@ def test_week_unplan_undo_rolls_the_offline_shell():
|
||||||
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
def test_private_today_action_mailbox_rolls_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
|
|
||||||
|
|
||||||
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
assert "BASE + 'static/week-plan.js'" in source
|
assert "BASE + 'static/week-plan.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
assert "BASE + 'static/my-work.js'" in source
|
assert "BASE + 'static/my-work.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
|
|
@ -216,7 +216,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/authored-outbox.js'" in source
|
assert "BASE + 'static/authored-outbox.js'" in source
|
||||||
assert "BASE + 'static/background-issue-sync.js'" in source
|
assert "BASE + 'static/background-issue-sync.js'" in source
|
||||||
|
|
@ -225,7 +225,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell():
|
||||||
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
assert "BASE + 'static/issue-evidence-review.js'" in source
|
assert "BASE + 'static/issue-evidence-review.js'" in source
|
||||||
assert "BASE + 'static/issue-attachment.js'" in source
|
assert "BASE + 'static/issue-attachment.js'" in source
|
||||||
|
|
||||||
|
|
@ -233,14 +233,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically():
|
||||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_offline_review_next_ships_today_completion_atomically():
|
def test_offline_review_next_ships_today_completion_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
assert "BASE + 'static/today-completion.js'" in source
|
assert "BASE + 'static/today-completion.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -248,7 +248,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
@ -256,7 +256,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
assert "BASE + 'static/issue-sheet.js'" in source
|
assert "BASE + 'static/issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/checklist-conflict.js'" in source
|
assert "BASE + 'static/checklist-conflict.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
@ -266,14 +266,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically():
|
||||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
assert "BASE + 'static/later-picker.js'" in source
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" 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
|
||||||
|
|
@ -282,21 +282,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
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-v133" in source
|
assert "stackchain-dashboard-shell-v134" 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-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1302,7 +1302,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
assert "BASE + 'static/queue-today.js'" in source
|
assert "BASE + 'static/queue-today.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||||
def test_readiness_runtime_is_available_in_offline_shell():
|
def test_readiness_runtime_is_available_in_offline_shell():
|
||||||
service_worker = SERVICE_WORKER.read_text()
|
service_worker = SERVICE_WORKER.read_text()
|
||||||
|
|
||||||
assert "const CACHE = 'stackchain-dashboard-shell-v133';" in service_worker
|
assert "const CACHE = 'stackchain-dashboard-shell-v134';" in service_worker
|
||||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete']();
|
||||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v133" in source
|
assert "stackchain-dashboard-shell-v134" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user