diff --git a/README.md b/README.md index 7b3a254..33cd37e 100644 --- a/README.md +++ b/README.md @@ -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, 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 -**Stop watching** removes it, while failed or unconfirmed Gitea mutations leave the collection -unchanged. Following counts never influence the recommended Work queue. Set +**Stop watching** removes an open item. When watched work closes, the sequential review exposes +**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`. Completed delegated issues remain in the mobile **Filed** queue until their latest outcome is acknowledged. diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 5ca52df..0d3ff56 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -5774,6 +5774,7 @@ if (!followingQueue.session()) taskOverlayHistory.update({preview:item}); }, onOpened:item => followingQueue.previewLoaded(item), + afterUnwatch:item => followingQueue.retire(item), navigationRoot:document, onState: renderSearchPreview, }); diff --git a/frontend/following.js b/frontend/following.js index 87ecab6..6d4a8c0 100644 --- a/frontend/following.js +++ b/frontend/following.js @@ -63,7 +63,7 @@ async function open(index) { const item = snapshot.items[Number(index)]; if (!item) return false; - await options.onOpen?.({...item, kind:'issue'}); + await options.onOpen?.({...item, kind:'issue', following:true}); await acknowledge(item); return true; } @@ -71,7 +71,7 @@ async function startReview() { const items = snapshot.items .filter(item => item.has_unseen_change === true) - .map(item => ({...item, kind:'issue'})); + .map(item => ({...item, kind:'issue', following:true})); if (!items.length) return false; review = {items, more:false, active:true, acknowledged:new Set()}; await open(snapshot.items.indexOf(snapshot.items.find(item => @@ -80,8 +80,8 @@ } function finishReview() { - const completed = Boolean(review?.active && - !snapshot.items.some(item => item.has_unseen_change === true)); + const completed = Boolean(review?.completed || (review?.active && + !snapshot.items.some(item => item.has_unseen_change === true))); if (review) review.active = false; if (completed && !review.completed) { review.completed = true; @@ -91,8 +91,30 @@ 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 { - load, open, startReview, previewLoaded:acknowledge, finishReview, + load, open, startReview, previewLoaded:acknowledge, finishReview, retire, session:() => review?.active ? {items:[...review.items], more:false} : null, items:() => snapshot.items.map(item => ({...item})), count:() => snapshot.items.length, @@ -179,6 +201,7 @@ }, session:feature.session, previewLoaded:feature.previewLoaded, + retire:feature.retire, returnToFollowing() { const completed = feature.finishReview(); if (completed) return 'completed-following'; diff --git a/frontend/search-preview.js b/frontend/search-preview.js index 894a7e8..b73df5c 100644 --- a/frontend/search-preview.js +++ b/frontend/search-preview.js @@ -34,8 +34,12 @@ '/comments?kind=' + encodeURIComponent(item.kind); root.searchPreviewSubscriptionPath = item => root.searchPreviewPath(item).replace(/\?.*$/, '') + '/subscription?kind=' + encodeURIComponent(item.kind); - root.searchPreviewSubscriptionOptions = fetchJson => ({ + root.searchPreviewSubscriptionOptions = fetchJson => { + const options = { 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; const result = await fetchJson(root.searchPreviewSubscriptionPath(detail), {headers:{Accept:'application/json'}}); return {...detail, watching:result.watching === true}; @@ -43,7 +47,12 @@ watch:(detail,watching) => fetchJson(root.searchPreviewSubscriptionPath(detail), { 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 => ({ watching:'Starting watch…', unwatching:'Stopping watch…', 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.', })[state.status] || ''; root.renderSearchPreviewWatch = (detail, state, button) => { - button.hidden = !(detail.kind === 'issue' && detail.state === 'open'); - button.textContent = detail.watching ? 'Stop watching' : 'Watch issue'; + const retiring = detail.kind === 'issue' && detail.state === 'closed' && + 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'; }; root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => { @@ -120,7 +132,7 @@ }; } })(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)) { getSession = session[0]; loadMore = () => session[1].loadMore(); @@ -385,11 +397,18 @@ } const detail = current; 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 }; const status = result?.following_synced === false ? 'watch-partial' : (watching ? 'watched' : 'unwatched'); 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; }).catch(error => { publish({ status:'watch-error', item:current, detail, error }); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 5cdff55..c413630 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,7 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/private-data-registry.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 OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; diff --git a/src/main.py b/src/main.py index 4ee19e8..7192e47 100644 --- a/src/main.py +++ b/src/main.py @@ -3738,7 +3738,7 @@ async def global_search_preview( 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]: repository = f"{owner}/{repo}" 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("kind") != "issue" 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") return repository, preview @@ -3790,7 +3790,9 @@ async def mutate_global_search_preview_subscription( ) -> JSONResponse: watching = request.method == "PUT" 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() store = _following_store() following_item = { diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py index c929ec1..773e9ff 100644 --- a/tests/test_command_palette.py +++ b/tests/test_command_palette.py @@ -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 -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""" require({json.dumps(str(SEARCH_PREVIEW))}); (async () => {{ @@ -925,7 +925,10 @@ for (const detail of [ globalThis.renderSearchPreviewWatch(detail, {{status:'ready'}}, candidate); 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);}}); """ 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, } 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(): diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index f5e97c7..4b506d9 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -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 button { min-height:44px;' in html 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 diff --git a/tests/test_following_api.py b/tests/test_following_api.py index 5e5aa70..12c51fa 100644 --- a/tests/test_following_api.py +++ b/tests/test_following_api.py @@ -249,3 +249,49 @@ async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch assert response.status_code == 200 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"] == [] diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py index 16e6777..65d1d8c 100644 --- a/tests/test_following_frontend.py +++ b/tests/test_following_frontend.py @@ -46,6 +46,7 @@ process.stdout.write(JSON.stringify(state)); "url": "https://forge.example/issue/42", "has_unseen_change": False, "kind": "issue", + "following": True, }] @@ -133,6 +134,7 @@ const feature = createFollowing({{ assert result["started"] is True 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["following"] is True for item in result["session"]["items"]) assert result["session"]["more"] is False assert result["state"] == {"opened": [42], "acknowledged": [42]} @@ -196,6 +198,46 @@ const feature = createFollowing({{ ).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(): script = f""" 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 "getSession:() => followingQueue.session() || commandSearchState" in dashboard assert "onOpened:item => followingQueue.previewLoaded(item)" in dashboard + assert "afterUnwatch:item => followingQueue.retire(item)" in dashboard assert "followingQueue.returnToFollowing()" in dashboard assert "'Back to Following'" in dashboard assert "if (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(): diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index fc473bf..cf8dd48 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -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(): 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 diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index f61a8dc..5716c5e 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -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 pre { max-width:100%; overflow-x:auto;" 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 diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 25cf1b9..f5b2e49 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -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])) 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(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index 7871f9c..151bbab 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "controller.recoverPermission('deadline')" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard 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-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_mobile_insights.py b/tests/test_mobile_insights.py index 4621501..e1755b0 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights( def test_mobile_insights_rolls_into_the_offline_shell(): 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 diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index 40962ed..e9c53ad 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -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 "max-width:100%; overflow-wrap:anywhere;" in html 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 diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 96e08fb..a2de650 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -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(): 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-readiness.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 544e0a3..48ca0a0 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -186,7 +186,7 @@ async function dispatchPush(payload) {{ def test_week_unplan_undo_rolls_the_offline_shell(): 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/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(): 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(): 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 def test_resumable_today_session_ships_in_a_new_offline_shell(): 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/dashboard.js'" 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(): 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/authored-outbox.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(): 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-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(): 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 def test_offline_review_next_ships_today_completion_atomically(): 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/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(): 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/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(): 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/checklist-conflict.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(): 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 def test_navigation_deadline_ships_in_a_new_shell_cache(): 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.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(): 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 def test_mobile_search_viewport_ships_in_a_new_offline_shell(): 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 def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): 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 @@ -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(): 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 diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index f531803..619f5e7 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -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(): 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 diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 88f25c6..bdce287 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete'](); 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-v133" in source + assert "stackchain-dashboard-shell-v134" in source assert "BASE + 'static/today-sync.js'" in source