Merge pull request 'Follow pull requests from mobile Search into Following' (#1308) from timmy/1307-follow-pulls-from-search into main
All checks were successful
CI / lint (push) Successful in 3m45s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 5m20s
CI / release-candidate (push) Successful in 9s

This commit is contained in:
rockachopa 2026-08-23 13:51:13 +00:00
commit 4e3f67df3d
23 changed files with 271 additions and 83 deletions

View File

@ -108,16 +108,16 @@ overwriting newer views. Rename and delete affect only the saved view, never Git
sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default sync service leaves ad-hoc Search usable. Set `STACKCHAIN_SAVED_SEARCH_DB` to override the default
`.stackchain-state/saved-searches.sqlite3` path. `.stackchain-state/saved-searches.sqlite3` path.
Confirmed **Watch issue** actions on any open issue also feed the mobile **Following** queue, Confirmed **Watch issue** and **Watch pull request** actions on open Search results feed the mobile
including work already assigned to you or a teammate. This completes the Search → watch → revisit **Following** queue, including work already assigned to you or a teammate. This completes the
flow without changing ownership or scheduling work. Following is a read-first, Search → watch → revisit flow without changing ownership, review assignment, or scheduling work.
account-scoped collection: it is encrypted at rest, revisioned, bounded to 50 canonical issues, Following is a read-first, account-scoped collection: it is encrypted at rest, revisioned, bounded to
and synchronized across signed-in devices. Opening a row reuses Search Preview; confirmed 50 typed items, and synchronized across signed-in devices. Opening a row reuses the correct issue or
**Stop watching** removes an open item. When watched work closes, the sequential review exposes pull-request Search Preview; confirmed **Stop watching** removes an open item. When watched work
**Stop watching & next** so the completed item can be retired without leaving the preview; the next closes or merges, the sequential review exposes **Stop watching & next** so the completed item can be
captured change opens immediately, and retiring the final item completes the Following phase. Failed retired without leaving the preview; the next captured change opens immediately, and retiring the
or unconfirmed Gitea mutations leave the collection and current review position unchanged. Following final item completes the Following phase. Failed or unconfirmed Gitea mutations leave the collection
counts never influence the recommended Work queue. Set 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.

View File

@ -13,6 +13,11 @@
let snapshot = {revision:0, items:[]}; let snapshot = {revision:0, items:[]};
let review = null; let review = null;
function sameItem(left, right) {
return (left?.kind || 'issue') === (right?.kind || 'issue') &&
left?.repository === right?.repository && Number(left?.number) === Number(right?.number);
}
function publish(status, error) { function publish(status, error) {
const state = {status, revision:snapshot.revision, items:[...snapshot.items]}; const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
state.degraded = snapshot.degraded === true; state.degraded = snapshot.degraded === true;
@ -36,7 +41,8 @@
if (requestGeneration !== generation) return snapshot; if (requestGeneration !== generation) return snapshot;
snapshot = { snapshot = {
revision:Number(result?.revision) || 0, revision:Number(result?.revision) || 0,
items:Array.isArray(result?.items) ? result.items.slice(0, 50) : [], items:Array.isArray(result?.items) ? result.items.slice(0, 50)
.map(item => ({...item, kind:item.kind === 'pull' ? 'pull' : 'issue'})) : [],
degraded:result?.degraded === true, degraded:result?.degraded === true,
refreshFailures:Number(result?.refresh_failures) || 0, refreshFailures:Number(result?.refresh_failures) || 0,
}; };
@ -49,13 +55,12 @@
} }
async function acknowledge(item) { async function acknowledge(item) {
const current = snapshot.items.find(candidate => const current = snapshot.items.find(candidate => sameItem(candidate, item) &&
candidate.repository === item?.repository && Number(candidate.number) === Number(item?.number) &&
candidate.updated_at === item?.updated_at); candidate.updated_at === item?.updated_at);
if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false; if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false;
await options.onAcknowledge(current); await options.onAcknowledge(current);
current.has_unseen_change = false; current.has_unseen_change = false;
review?.acknowledged.add(current.repository + '#' + current.number + '@' + current.updated_at); review?.acknowledged.add(current.kind + ':' + current.repository + '#' + current.number + '@' + current.updated_at);
publish('ready'); publish('ready');
return true; return true;
} }
@ -63,7 +68,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', following:true}); await options.onOpen?.({...item, following:true});
await acknowledge(item); await acknowledge(item);
return true; return true;
} }
@ -71,11 +76,10 @@
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', following:true})); .map(item => ({...item, 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 => sameItem(item, items[0]))));
item.repository === items[0].repository && Number(item.number) === Number(items[0].number))));
return true; return true;
} }
@ -92,8 +96,7 @@
} }
function retire(item) { function retire(item) {
const same = candidate => candidate.repository === item?.repository && const same = candidate => sameItem(candidate, item);
Number(candidate.number) === Number(item?.number);
const index = review?.active ? review.items.findIndex(same) : -1; const index = review?.active ? review.items.findIndex(same) : -1;
snapshot.items = snapshot.items.filter(candidate => !same(candidate)); snapshot.items = snapshot.items.filter(candidate => !same(candidate));
if (index < 0) { if (index < 0) {
@ -140,21 +143,22 @@
const status = query('#following-status'); const status = query('#following-status');
const reviewButton = query('#review-following'); const reviewButton = query('#review-following');
query('#retry-following').hidden = state.status !== 'error'; query('#retry-following').hidden = state.status !== 'error';
if (state.status === 'loading') return void (status.textContent = 'Loading watched issues…'); if (state.status === 'loading') return void (status.textContent = 'Loading watched items…');
if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.'); if (state.status === 'error') return void (status.textContent = state.error?.message || 'Following is temporarily unavailable.');
const unseen = state.items.filter(item => item.has_unseen_change === true).length; const unseen = state.items.filter(item => item.has_unseen_change === true).length;
reviewButton.hidden = unseen === 0; reviewButton.hidden = unseen === 0;
reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes'; reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes';
status.textContent = (state.reviewSummary status.textContent = (state.reviewSummary
? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. ' ? 'Reviewed ' + state.reviewSummary.reviewed + ' changes · ' + state.reviewSummary.remaining + ' still need review. '
: '') + (state.degraded ? 'Some watched issues could not be refreshed. Showing last known details. ' : '') + (state.items.length : '') + (state.degraded ? 'Some watched items could not be refreshed. Showing last known details. ' : '') + (state.items.length
? state.items.length + (state.items.length === 1 ? ' watched issue.' : ' watched issues.') ? state.items.length + (state.items.length === 1 ? ' watched item.' : ' watched items.')
: 'No watched issues yet. Watch one from Search to keep it here.'); : 'No watched items yet. Watch an issue or pull request from Search to keep it here.');
list.innerHTML = state.items.map((item, index) => list.innerHTML = state.items.map((item, index) =>
'<button class="following-card' + (item.has_unseen_change ? ' has-unseen-change' : '') + '<button class="following-card' + (item.has_unseen_change ? ' has-unseen-change' : '') +
'" type="button" data-following-index="' + index + '"><span>' + '" type="button" data-following-index="' + index + '"><span>' +
(item.has_unseen_change ? '<em>New activity</em>' : '') + '<strong>' + (item.has_unseen_change ? '<em>New activity</em>' : '') + '<strong>' +
escapeHtml(item.title) + '</strong><small>' + escapeHtml(item.repository + ' #' + item.number + escapeHtml(item.title) + '</strong><small>' + escapeHtml(
(item.kind === 'pull' ? 'Pull request' : 'Issue') + ' · ' + item.repository + ' #' + item.number +
' · ' + item.state + ' · ' + formatTime(item.updated_at)) + ' · ' + item.state + ' · ' + formatTime(item.updated_at)) +
'</small></span><span aria-hidden="true"></span></button>').join(''); '</small></span><span aria-hidden="true"></span></button>').join('');
list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => { list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => {
@ -176,7 +180,8 @@
onAcknowledge:item => { onAcknowledge:item => {
const [owner, repo] = item.repository.split('/'); const [owner, repo] = item.repository.split('/');
return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' + return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' +
encodeURIComponent(repo) + '/issues/' + item.number + '/seen', { encodeURIComponent(repo) + '/issues/' + item.number +
'/seen?kind=' + encodeURIComponent(item.kind), {
method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'}, method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'},
body:JSON.stringify({updated_at:item.updated_at}), body:JSON.stringify({updated_at:item.updated_at}),
}); });

View File

@ -2016,7 +2016,7 @@
<button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button> <button data-mobile-queue="delivery" type="button"><span><strong>Delivery</strong><small>Needs recovery</small></span><span data-mobile-queue-count="delivery">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button> <button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button> <button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
<button data-mobile-queue="following" type="button" aria-label="Following, 0 watched issues"><span><strong>Following</strong><small>Issues you watch</small></span><span data-mobile-queue-count="following">0</span></button> <button data-mobile-queue="following" type="button" aria-label="Following, 0 unseen changes"><span><strong>Following</strong><small>Issues and pull requests you watch</small></span><span data-mobile-queue-count="following">0</span></button>
<button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button> <button data-mobile-queue="filed" type="button"><span><strong>Filed</strong><small>Issues you delegated</small></span><span data-mobile-queue-count="filed">0</span></button>
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button> <button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button> <button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>

View File

@ -37,10 +37,10 @@
root.searchPreviewSubscriptionOptions = fetchJson => { root.searchPreviewSubscriptionOptions = fetchJson => {
const options = { const options = {
load:async detail => { load:async detail => {
if (detail.kind === 'issue' && detail.state === 'closed' && detail.following === true) { if (['issue', 'pull'].includes(detail.kind) && detail.state === 'closed' && detail.following === true) {
return {...detail, watching:true}; return {...detail, watching:true};
} }
if (!(detail.kind === 'issue' && detail.state === 'open')) return detail; if (!(['issue', 'pull'].includes(detail.kind) && 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};
}, },
@ -57,15 +57,17 @@
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.',
unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.', unwatched:'Stopped watching · removed from Following. Assignment and planning are unchanged.',
'watch-partial':'Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch issue to repair.', 'watch-partial':'Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch ' +
(state.detail?.kind === 'pull' ? 'pull request' : 'issue') + ' to repair.',
'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) => {
const retiring = detail.kind === 'issue' && detail.state === 'closed' && const watchableKind = ['issue', 'pull'].includes(detail.kind);
const retiring = watchableKind && detail.state === 'closed' &&
detail.following === true && detail.watching === true; detail.following === true && detail.watching === true;
button.hidden = !(detail.kind === 'issue' && detail.state === 'open') && !retiring; button.hidden = !(watchableKind && detail.state === 'open') && !retiring;
button.textContent = retiring ? 'Stop watching & next' : button.textContent = retiring ? 'Stop watching & next' :
(detail.watching ? 'Stop watching' : 'Watch issue'); (detail.watching ? 'Stop watching' : 'Watch ' + (detail.kind === 'pull' ? 'pull request' : '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', () => {

View File

@ -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-v134'; const CACHE = 'stackchain-dashboard-shell-v135';
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;

View File

@ -10,6 +10,7 @@ from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") _REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
_STATES = {"open", "closed"} _STATES = {"open", "closed"}
_KINDS = {"issue", "pull"}
class FollowingStore: class FollowingStore:
@ -74,6 +75,9 @@ class FollowingStore:
number = raw.get("number") number = raw.get("number")
if not isinstance(number, int) or isinstance(number, bool) or number < 1: if not isinstance(number, int) or isinstance(number, bool) or number < 1:
raise ValueError("number is invalid") raise ValueError("number is invalid")
kind = raw.get("kind", "issue")
if kind not in _KINDS:
raise ValueError("kind is invalid")
title = raw.get("title") title = raw.get("title")
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 300: if not isinstance(title, str) or not title.strip() or len(title.strip()) > 300:
raise ValueError("title is invalid") raise ValueError("title is invalid")
@ -95,6 +99,7 @@ class FollowingStore:
raise ValueError("last seen update is invalid") raise ValueError("last seen update is invalid")
return { return {
"repository": repository, "repository": repository,
"kind": kind,
"number": number, "number": number,
"title": title.strip(), "title": title.strip(),
"state": state, "state": state,
@ -117,8 +122,8 @@ class FollowingStore:
return {"revision": snapshot["revision"], "items": changed + unchanged} return {"revision": snapshot["revision"], "items": changed + unchanged}
@staticmethod @staticmethod
def _identity(item: dict) -> tuple[str, int]: def _identity(item: dict) -> tuple[str, str, int]:
return item["repository"].lower(), item["number"] return item.get("kind", "issue"), item["repository"].lower(), item["number"]
def get(self, login: str) -> dict: def get(self, login: str) -> dict:
login = self._login(login) login = self._login(login)
@ -226,10 +231,20 @@ class FollowingStore:
) )
return self._present({"revision": revision, "items": items}) return self._present({"revision": revision, "items": items})
def acknowledge(self, login: str, repository: str, number: int, updated_at: str) -> dict: def acknowledge(
self,
login: str,
repository: str,
number: int,
updated_at: str,
*,
kind: str = "issue",
) -> dict:
"""Mark only the exact upstream revision successfully opened by the operator.""" """Mark only the exact upstream revision successfully opened by the operator."""
login = self._login(login) login = self._login(login)
identity = (str(repository).lower(), number) if kind not in _KINDS:
raise ValueError("kind is invalid")
identity = (kind, str(repository).lower(), number)
with self._connect() as connection: with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE") connection.execute("BEGIN IMMEDIATE")
row = connection.execute( row = connection.execute(

View File

@ -2623,11 +2623,12 @@ async def get_following(response: Response):
async def refresh_item(item: dict) -> dict: async def refresh_item(item: dict) -> dict:
async with semaphore: async with semaphore:
preview = await asyncio.wait_for( preview = await asyncio.wait_for(
gitea_proxy.work_preview(item["repository"], "issue", item["number"]), gitea_proxy.work_preview(item["repository"], item["kind"], item["number"]),
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS, timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
) )
return store._normalize_item({ return store._normalize_item({
"repository": item["repository"], "repository": item["repository"],
"kind": item["kind"],
"number": item["number"], "number": item["number"],
"title": preview.get("title", ""), "title": preview.get("title", ""),
"state": preview.get("state", ""), "state": preview.get("state", ""),
@ -2658,6 +2659,7 @@ async def acknowledge_following_revision(
owner: str, owner: str,
repo: str, repo: str,
number: int = PathParam(gt=0), number: int = PathParam(gt=0),
kind: Literal["issue", "pull"] = Query(default="issue"),
): ):
login = await _confirmed_login() login = await _confirmed_login()
try: try:
@ -2667,6 +2669,7 @@ async def acknowledge_following_revision(
f"{owner}/{repo}", f"{owner}/{repo}",
number, number,
payload.updated_at, payload.updated_at,
kind=kind,
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
@ -3743,9 +3746,8 @@ async def _search_preview_subscription_target(
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)
if ( if (
kind != "issue" preview.get("repository") != repository
or preview.get("repository") != repository or preview.get("kind") != kind
or preview.get("kind") != "issue"
or preview.get("number") != number or preview.get("number") != number
or preview.get("state") not in ({"open", "closed"} if allow_closed else {"open"}) or preview.get("state") not in ({"open", "closed"} if allow_closed else {"open"})
): ):
@ -3797,6 +3799,7 @@ async def mutate_global_search_preview_subscription(
store = _following_store() store = _following_store()
following_item = { following_item = {
"repository": repository, "repository": repository,
"kind": kind,
"number": number, "number": number,
"title": preview.get("title", ""), "title": preview.get("title", ""),
"state": preview.get("state", ""), "state": preview.get("state", ""),

View File

@ -13,25 +13,39 @@ ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend" FRONTEND = ROOT / "frontend"
def test_following_queue_is_phone_usable_at_narrow_viewport(): @pytest.mark.parametrize("viewport", [
{"width": 320, "height": 568},
{"width": 390, "height": 844},
])
def test_following_queue_is_phone_usable_at_narrow_viewport(viewport):
with sync_playwright() as playwright: with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True) browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 320, "height": 568}) page = browser.new_page(viewport=viewport)
page.set_content((FRONTEND / "index.html").read_text()) page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css") page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "search-preview.js") page.add_script_tag(path=FRONTEND / "search-preview.js")
page.add_script_tag(path=FRONTEND / "following.js")
row = page.locator('[data-mobile-queue="following"]') row = page.locator('[data-mobile-queue="following"]')
expect(row).to_have_count(1) expect(row).to_have_count(1)
page.locator("#following-list").evaluate("""node => { expect(row).to_contain_text("Issues and pull requests you watch")
node.innerHTML = '<button class="following-card has-unseen-change" type="button"><span><em>New activity</em><strong>Changed issue with a long mobile title</strong><small>stackchain/api #42 · open · just now</small></span><span aria-hidden="true"></span></button>'; page.evaluate("""() => {
globalThis.fetch = async () => ({
ok:true,
json:async () => ({revision:1,items:[{
repository:'stackchain/api', kind:'pull', number:42,
title:'Changed pull request with a long mobile title', state:'open',
updated_at:'2026-08-23T05:00:00Z', has_unseen_change:true
}]})
});
globalThis.followingReleaseQueue = attachFollowing(() => {});
globalThis.followingReleaseQueue.open();
}""") }""")
page.locator("#review-following").evaluate("node => node.hidden = false")
page.locator("#following-sheet").evaluate("node => node.showModal()")
expect(page.locator("#following-sheet")).to_be_visible() expect(page.locator("#following-sheet")).to_be_visible()
expect(page.locator(".following-card")).to_be_visible() expect(page.locator(".following-card")).to_be_visible()
expect(page.locator(".following-card")).to_contain_text("New activity") expect(page.locator(".following-card")).to_contain_text("New activity")
expect(page.locator(".following-card")).to_contain_text("Pull request")
expect(page.locator("#review-following")).to_have_text("Review new activity") expect(page.locator("#review-following")).to_have_text("Review new activity")
assert page.locator("#review-following").bounding_box()["height"] >= 44 assert page.locator("#review-following").bounding_box()["height"] >= 44
assert page.locator(".following-card").bounding_box()["height"] >= 44 assert page.locator(".following-card").bounding_box()["height"] >= 44
@ -52,6 +66,11 @@ def test_following_queue_is_phone_usable_at_narrow_viewport():
expect(retire).to_be_visible() expect(retire).to_be_visible()
expect(retire).to_have_text("Stop watching & next") expect(retire).to_have_text("Stop watching & next")
assert retire.bounding_box()["height"] >= 44 assert retire.bounding_box()["height"] >= 44
page.evaluate("""() => renderSearchPreviewWatch(
{repository:'stackchain/api', number:42, kind:'pull', state:'open', watching:false},
{status:'ready'}, document.querySelector('#watch-search-result'))""")
expect(retire).to_have_text("Watch pull request")
assert retire.bounding_box()["height"] >= 44
overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth") overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth")
assert overflow is False assert overflow is False
browser.close() browser.close()

View File

@ -864,7 +864,7 @@ def test_search_preview_preserves_authoritative_watch_and_offers_following_repai
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))}); const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{ (async () => {{
const states = []; const states = [];
const detail = {{repository:'stackchain/api',number:42,kind:'issue',state:'open',claimable:true,watching:false}}; const detail = {{repository:'stackchain/api',number:42,kind:'pull',state:'open',claimable:true,watching:false}};
const preview = createSearchPreview({{ const preview = createSearchPreview({{
fetchJson:async()=>detail, fetchJson:async()=>detail,
watch:async()=>({{watching:true,following_synced:false,error:'Watching in Gitea, but Following could not sync. Retry this action.'}}), watch:async()=>({{watching:true,following_synced:false,error:'Watching in Gitea, but Following could not sync. Retry this action.'}}),
@ -886,7 +886,7 @@ process.stdout.write(JSON.stringify({{
assert json.loads(result.stdout) == { assert json.loads(result.stdout) == {
"status": "watch-partial", "status": "watch-partial",
"watching": True, "watching": True,
"message": "Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch issue to repair.", "message": "Watching in Gitea, but Following could not sync. Tap Stop watching, then Watch pull request to repair.",
} }
@ -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_open_issues_and_closed_following_retirement(): def test_search_preview_watch_is_available_for_open_issues_pulls_and_closed_following_retirement():
script = f""" script = f"""
require({json.dumps(str(SEARCH_PREVIEW))}); require({json.dumps(str(SEARCH_PREVIEW))});
(async () => {{ (async () => {{
@ -936,7 +936,8 @@ process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,e
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout) payload = json.loads(result.stdout)
assert payload["loaded"] == [ assert payload["loaded"] == [
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue" "api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=issue",
"api/v1/repos/stackchain/api/issues/42/preview/subscription?kind=pull",
] ]
assert payload["watching"] is True assert payload["watching"] is True
assert payload["button"] == { assert payload["button"] == {
@ -944,7 +945,7 @@ process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,e
"textContent": "Stop watching", "textContent": "Stop watching",
"disabled": False, "disabled": False,
} }
assert payload["excluded"] == [True, True] assert payload["excluded"] == [True, False]
assert payload["closedFollowing"]["watching"] is True assert payload["closedFollowing"]["watching"] is True
assert payload["retire"] == { assert payload["retire"] == {
"hidden": False, "hidden": False,

View File

@ -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-v134" in worker assert "stackchain-dashboard-shell-v135" in worker

View File

@ -52,6 +52,7 @@ async def test_confirmed_watch_updates_account_following_collection(monkeypatch,
assert following.headers["cache-control"] == "no-store" assert following.headers["cache-control"] == "no-store"
assert following.json() == {"revision": 1, "degraded": False, "refresh_failures": 0, "items": [{ assert following.json() == {"revision": 1, "degraded": False, "refresh_failures": 0, "items": [{
"repository": "stackchain/api", "repository": "stackchain/api",
"kind": "issue",
"number": 42, "number": 42,
"title": "Make mobile review useful", "title": "Make mobile review useful",
"state": "open", "state": "open",
@ -61,6 +62,47 @@ async def test_confirmed_watch_updates_account_following_collection(monkeypatch,
}]} }]}
@pytest.mark.anyio
async def test_confirmed_pull_watch_round_trips_through_following(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"p" * 32)
detail = {
"repository": "stackchain/api", "kind": "pull", "number": 84,
"title": "Ship typed Following", "state": "open",
"updated_at": "2026-08-23T05:00:00Z",
"url": "https://forge.example/stackchain/api/pulls/84",
}
previews = []
async def preview(repository, kind, number):
previews.append((repository, kind, number))
return detail
async def set_subscription(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:
watched = await client.put(
"/api/v1/repos/stackchain/api/issues/84/preview/subscription?kind=pull"
)
following = await client.get("/api/v1/following")
assert watched.status_code == 200
assert following.status_code == 200
assert following.json()["items"][0]["kind"] == "pull"
assert previews == [
("stackchain/api", "pull", 84),
("stackchain/api", "pull", 84),
]
@pytest.mark.anyio @pytest.mark.anyio
async def test_full_following_collection_rejects_watch_before_gitea_mutation(monkeypatch, tmp_path): async def test_full_following_collection_rejects_watch_before_gitea_mutation(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"b" * 32, limit=1) store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"b" * 32, limit=1)
@ -228,7 +270,7 @@ async def test_following_refreshes_changed_items_and_preserves_failed_items(monk
async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch, tmp_path): async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"f" * 32) store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"f" * 32)
item = { item = {
"repository": "stackchain/api", "number": 42, "title": "Changed", "repository": "stackchain/api", "kind": "pull", "number": 42, "title": "Changed",
"state": "open", "updated_at": "2026-08-23T03:00:00Z", "state": "open", "updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42", "url": "https://forge.example/stackchain/api/issues/42",
} }
@ -243,7 +285,7 @@ async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch
transport = httpx.ASGITransport(app=main.app) transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.put( response = await client.put(
"/api/v1/following/stackchain/api/issues/42/seen", "/api/v1/following/stackchain/api/issues/42/seen?kind=pull",
json={"updated_at": "2026-08-23T04:00:00Z"}, json={"updated_at": "2026-08-23T04:00:00Z"},
) )

View File

@ -50,6 +50,61 @@ process.stdout.write(JSON.stringify(state));
}] }]
def test_following_opens_pull_requests_in_the_pull_workspace():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const opened=[];
const feature=createFollowing({{
fetchJson:async () => ({{revision:1,items:[{{
repository:'stackchain/api',kind:'pull',number:84,title:'Typed review',state:'open',
updated_at:'2026-08-23T05:00:00Z',url:'https://forge.example/pulls/84',has_unseen_change:true
}}]}}),
onOpen:async item => opened.push(item), onAcknowledge:async () => {{}},
}});
(async () => {{
await feature.load();
await feature.startReview();
process.stdout.write(JSON.stringify({{opened,session:feature.session()}}));
}})().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["opened"][0]["kind"] == "pull"
assert result["opened"][0]["following"] is True
assert result["session"]["items"][0]["kind"] == "pull"
def test_following_typed_identity_prevents_issue_pull_collisions():
script = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const acknowledged=[];
const items=['issue','pull'].map(kind => ({{
repository:'stackchain/api',kind,number:84,title:kind,state:'closed',
updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true
}}));
const feature=createFollowing({{
fetchJson:async () => ({{revision:1,items}}),
onOpen:async () => {{}}, onAcknowledge:async item => acknowledged.push(item.kind),
}});
(async () => {{
await feature.load();
await feature.previewLoaded({{...items[1]}});
feature.retire(items[1]);
process.stdout.write(JSON.stringify({{acknowledged,items:feature.items()}}));
}})().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["acknowledged"] == ["pull"]
assert [(item["kind"], item["has_unseen_change"]) for item in result["items"]] == [
("issue", True)
]
def test_following_opens_explicitly_but_never_becomes_work_recommendation(): def test_following_opens_explicitly_but_never_becomes_work_recommendation():
launcher = ROOT / "frontend" / "mobile-queue-launcher.js" launcher = ROOT / "frontend" / "mobile-queue-launcher.js"
script = f""" script = f"""
@ -292,12 +347,13 @@ 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 "'/seen?kind=' + encodeURIComponent(item.kind)" in following
assert "afterUnwatch:item => followingQueue.retire(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-v134" in service_worker assert "stackchain-dashboard-shell-v135" in service_worker
def test_prepare_today_lazily_refreshes_and_directly_reviews_following(): def test_prepare_today_lazily_refreshes_and_directly_reviews_following():

View File

@ -21,7 +21,10 @@ def test_confirmed_watch_is_account_scoped_idempotent_and_encrypted(tmp_path):
first = store.set_watching("Timmy", ITEM, True) first = store.set_watching("Timmy", ITEM, True)
repeated = store.set_watching("timmy", ITEM, True) repeated = store.set_watching("timmy", ITEM, True)
assert first == repeated == {"revision": 1, "items": [{**ITEM, "has_unseen_change": False}]} assert first == repeated == {
"revision": 1,
"items": [{**ITEM, "kind": "issue", "has_unseen_change": False}],
}
assert store.get("other") == {"revision": 0, "items": []} assert store.get("other") == {"revision": 0, "items": []}
stored = sqlite3.connect(path).execute( stored = sqlite3.connect(path).execute(
"SELECT items FROM following_issues WHERE login = ?", ("timmy",) "SELECT items FROM following_issues WHERE login = ?", ("timmy",)
@ -72,3 +75,45 @@ def test_reconfirmed_watch_does_not_mark_an_unseen_change_as_seen(tmp_path):
snapshot = store.set_watching("timmy", changed, True) snapshot = store.set_watching("timmy", changed, True)
assert snapshot["items"][0]["has_unseen_change"] is True assert snapshot["items"][0]["has_unseen_change"] is True
def test_following_preserves_kind_and_uses_it_in_item_identity(tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
issue = {**ITEM, "kind": "issue"}
pull = {
**ITEM,
"kind": "pull",
"title": "Review the mobile flow",
"url": "https://forge.example/stackchain/api/pulls/42",
}
store.set_watching("timmy", issue, True)
snapshot = store.set_watching("timmy", pull, True)
assert [(item["kind"], item["number"]) for item in snapshot["items"]] == [
("pull", 42),
("issue", 42),
]
def test_following_reads_legacy_items_as_issues(tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
store.set_watching("timmy", ITEM, True)
assert store.get("timmy")["items"][0]["kind"] == "issue"
def test_acknowledgement_only_marks_the_requested_item_kind_seen(tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY)
for kind in ("issue", "pull"):
store.set_watching("timmy", {**ITEM, "kind": kind}, True)
store.refresh("timmy", [{**ITEM, "kind": kind, "updated_at": "2026-08-23T06:00:00Z"}])
snapshot = store.acknowledge(
"timmy", ITEM["repository"], ITEM["number"], "2026-08-23T06:00:00Z", kind="pull"
)
assert {item["kind"]: item["has_unseen_change"] for item in snapshot["items"]} == {
"issue": True,
"pull": False,
}

View File

@ -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-v134" in source assert "stackchain-dashboard-shell-v135" in source
assert "BASE + 'static/later-sync.js'" in source assert "BASE + 'static/later-sync.js'" in source

View File

@ -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-v134" in worker assert "stackchain-dashboard-shell-v135" in worker

View File

@ -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-v134" in worker assert "stackchain-dashboard-shell-v135" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions(): def test_all_conversation_composers_offer_accessible_mobile_mentions():

View File

@ -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-v134" in worker assert "stackchain-dashboard-shell-v135" 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

View File

@ -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-v134" in worker assert "stackchain-dashboard-shell-v135" in worker
assert "BASE + 'static/mobile-insights.js'" in worker assert "BASE + 'static/mobile-insights.js'" in worker

View File

@ -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-v134" in service_worker assert "stackchain-dashboard-shell-v135" in service_worker
@pytest.mark.anyio @pytest.mark.anyio

View File

@ -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-v134" in source assert "stackchain-dashboard-shell-v135" 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

View File

@ -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-v134" in source assert "stackchain-dashboard-shell-v135" 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-v134" in source assert "stackchain-dashboard-shell-v135" 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-v134" in source assert "stackchain-dashboard-shell-v135" 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-v134" in source assert "stackchain-dashboard-shell-v135" 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-v134" in source assert "stackchain-dashboard-shell-v135" 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-v134" in source assert "stackchain-dashboard-shell-v135" 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-v134" in source assert "stackchain-dashboard-shell-v135" 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-v134" in source assert "stackchain-dashboard-shell-v135" 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-v134" in source assert "stackchain-dashboard-shell-v135" 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-v134" in source assert "stackchain-dashboard-shell-v135" 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-v134" in source assert "stackchain-dashboard-shell-v135" 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-v134" in source assert "stackchain-dashboard-shell-v135" 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-v134" in source assert "stackchain-dashboard-shell-v135" 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-v134" in source assert "stackchain-dashboard-shell-v135" 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-v134" in source assert "stackchain-dashboard-shell-v135" 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-v134" in source assert "stackchain-dashboard-shell-v135" in source
assert "BASE + 'static/queue-today.js'" in source assert "BASE + 'static/queue-today.js'" in source

View File

@ -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-v134';" in service_worker assert "const CACHE = 'stackchain-dashboard-shell-v135';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker

View File

@ -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-v134" in source assert "stackchain-dashboard-shell-v135" in source
assert "BASE + 'static/today-sync.js'" in source assert "BASE + 'static/today-sync.js'" in source