Follow pull requests from mobile Search into Following #1308
20
README.md
20
README.md
|
|
@ -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
|
||||
`.stackchain-state/saved-searches.sqlite3` path.
|
||||
|
||||
Confirmed **Watch issue** actions on any open issue also feed the mobile **Following** queue,
|
||||
including work already assigned to you or a teammate. This completes the Search → watch → revisit
|
||||
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 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
|
||||
Confirmed **Watch issue** and **Watch pull request** actions on open Search results feed the mobile
|
||||
**Following** queue, including work already assigned to you or a teammate. This completes the
|
||||
Search → watch → revisit flow without changing ownership, review assignment, or scheduling work.
|
||||
Following is a read-first, account-scoped collection: it is encrypted at rest, revisioned, bounded to
|
||||
50 typed items, and synchronized across signed-in devices. Opening a row reuses the correct issue or
|
||||
pull-request Search Preview; confirmed **Stop watching** removes an open item. When watched work
|
||||
closes or merges, 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.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@
|
|||
let snapshot = {revision:0, items:[]};
|
||||
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) {
|
||||
const state = {status, revision:snapshot.revision, items:[...snapshot.items]};
|
||||
state.degraded = snapshot.degraded === true;
|
||||
|
|
@ -36,7 +41,8 @@
|
|||
if (requestGeneration !== generation) return snapshot;
|
||||
snapshot = {
|
||||
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,
|
||||
refreshFailures:Number(result?.refresh_failures) || 0,
|
||||
};
|
||||
|
|
@ -49,13 +55,12 @@
|
|||
}
|
||||
|
||||
async function acknowledge(item) {
|
||||
const current = snapshot.items.find(candidate =>
|
||||
candidate.repository === item?.repository && Number(candidate.number) === Number(item?.number) &&
|
||||
const current = snapshot.items.find(candidate => sameItem(candidate, item) &&
|
||||
candidate.updated_at === item?.updated_at);
|
||||
if (!current || current.has_unseen_change !== true || typeof options.onAcknowledge !== 'function') return false;
|
||||
await options.onAcknowledge(current);
|
||||
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');
|
||||
return true;
|
||||
}
|
||||
|
|
@ -63,7 +68,7 @@
|
|||
async function open(index) {
|
||||
const item = snapshot.items[Number(index)];
|
||||
if (!item) return false;
|
||||
await options.onOpen?.({...item, kind:'issue', following:true});
|
||||
await options.onOpen?.({...item, following:true});
|
||||
await acknowledge(item);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -71,11 +76,10 @@
|
|||
async function startReview() {
|
||||
const items = snapshot.items
|
||||
.filter(item => item.has_unseen_change === true)
|
||||
.map(item => ({...item, kind:'issue', following:true}));
|
||||
.map(item => ({...item, 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 =>
|
||||
item.repository === items[0].repository && Number(item.number) === Number(items[0].number))));
|
||||
await open(snapshot.items.indexOf(snapshot.items.find(item => sameItem(item, items[0]))));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -92,8 +96,7 @@
|
|||
}
|
||||
|
||||
function retire(item) {
|
||||
const same = candidate => candidate.repository === item?.repository &&
|
||||
Number(candidate.number) === Number(item?.number);
|
||||
const same = candidate => sameItem(candidate, item);
|
||||
const index = review?.active ? review.items.findIndex(same) : -1;
|
||||
snapshot.items = snapshot.items.filter(candidate => !same(candidate));
|
||||
if (index < 0) {
|
||||
|
|
@ -140,21 +143,22 @@
|
|||
const status = query('#following-status');
|
||||
const reviewButton = query('#review-following');
|
||||
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.');
|
||||
const unseen = state.items.filter(item => item.has_unseen_change === true).length;
|
||||
reviewButton.hidden = unseen === 0;
|
||||
reviewButton.textContent = unseen === 1 ? 'Review new activity' : 'Review ' + unseen + ' new changes';
|
||||
status.textContent = (state.reviewSummary
|
||||
? '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.items.length + (state.items.length === 1 ? ' watched issue.' : ' watched issues.')
|
||||
: 'No watched issues yet. Watch one from Search to keep it here.');
|
||||
: '') + (state.degraded ? 'Some watched items could not be refreshed. Showing last known details. ' : '') + (state.items.length
|
||||
? state.items.length + (state.items.length === 1 ? ' watched item.' : ' watched items.')
|
||||
: 'No watched items yet. Watch an issue or pull request from Search to keep it here.');
|
||||
list.innerHTML = state.items.map((item, index) =>
|
||||
'<button class="following-card' + (item.has_unseen_change ? ' has-unseen-change' : '') +
|
||||
'" type="button" data-following-index="' + index + '"><span>' +
|
||||
(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)) +
|
||||
'</small></span><span aria-hidden="true">›</span></button>').join('');
|
||||
list.querySelectorAll('[data-following-index]').forEach(button => button.addEventListener('click', () => {
|
||||
|
|
@ -176,7 +180,8 @@
|
|||
onAcknowledge:item => {
|
||||
const [owner, repo] = item.repository.split('/');
|
||||
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'},
|
||||
body:JSON.stringify({updated_at:item.updated_at}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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="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="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="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>
|
||||
|
|
|
|||
|
|
@ -37,10 +37,10 @@
|
|||
root.searchPreviewSubscriptionOptions = fetchJson => {
|
||||
const options = {
|
||||
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};
|
||||
}
|
||||
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'}});
|
||||
return {...detail, watching:result.watching === true};
|
||||
},
|
||||
|
|
@ -57,15 +57,17 @@
|
|||
watching:'Starting watch…', unwatching:'Stopping watch…',
|
||||
watched:'Watching · available in Following. Future activity will appear in Updates.',
|
||||
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.',
|
||||
})[state.status] || '';
|
||||
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;
|
||||
button.hidden = !(detail.kind === 'issue' && detail.state === 'open') && !retiring;
|
||||
button.hidden = !(watchableKind && detail.state === 'open') && !retiring;
|
||||
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';
|
||||
};
|
||||
root.wireSearchPreviewWatch = (button, preview, getDetail) => button.addEventListener('click', () => {
|
||||
|
|
|
|||
|
|
@ -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-v134';
|
||||
const CACHE = 'stackchain-dashboard-shell-v135';
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from src.state_encryption import PrivateStateCipher, PrivateStateEncryptionError
|
|||
|
||||
_REPOSITORY = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
_STATES = {"open", "closed"}
|
||||
_KINDS = {"issue", "pull"}
|
||||
|
||||
|
||||
class FollowingStore:
|
||||
|
|
@ -74,6 +75,9 @@ class FollowingStore:
|
|||
number = raw.get("number")
|
||||
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
||||
raise ValueError("number is invalid")
|
||||
kind = raw.get("kind", "issue")
|
||||
if kind not in _KINDS:
|
||||
raise ValueError("kind is invalid")
|
||||
title = raw.get("title")
|
||||
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 300:
|
||||
raise ValueError("title is invalid")
|
||||
|
|
@ -95,6 +99,7 @@ class FollowingStore:
|
|||
raise ValueError("last seen update is invalid")
|
||||
return {
|
||||
"repository": repository,
|
||||
"kind": kind,
|
||||
"number": number,
|
||||
"title": title.strip(),
|
||||
"state": state,
|
||||
|
|
@ -117,8 +122,8 @@ class FollowingStore:
|
|||
return {"revision": snapshot["revision"], "items": changed + unchanged}
|
||||
|
||||
@staticmethod
|
||||
def _identity(item: dict) -> tuple[str, int]:
|
||||
return item["repository"].lower(), item["number"]
|
||||
def _identity(item: dict) -> tuple[str, str, int]:
|
||||
return item.get("kind", "issue"), item["repository"].lower(), item["number"]
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
login = self._login(login)
|
||||
|
|
@ -226,10 +231,20 @@ class FollowingStore:
|
|||
)
|
||||
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."""
|
||||
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:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
|
|
|
|||
11
src/main.py
11
src/main.py
|
|
@ -2623,11 +2623,12 @@ async def get_following(response: Response):
|
|||
async def refresh_item(item: dict) -> dict:
|
||||
async with semaphore:
|
||||
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,
|
||||
)
|
||||
return store._normalize_item({
|
||||
"repository": item["repository"],
|
||||
"kind": item["kind"],
|
||||
"number": item["number"],
|
||||
"title": preview.get("title", ""),
|
||||
"state": preview.get("state", ""),
|
||||
|
|
@ -2658,6 +2659,7 @@ async def acknowledge_following_revision(
|
|||
owner: str,
|
||||
repo: str,
|
||||
number: int = PathParam(gt=0),
|
||||
kind: Literal["issue", "pull"] = Query(default="issue"),
|
||||
):
|
||||
login = await _confirmed_login()
|
||||
try:
|
||||
|
|
@ -2667,6 +2669,7 @@ async def acknowledge_following_revision(
|
|||
f"{owner}/{repo}",
|
||||
number,
|
||||
payload.updated_at,
|
||||
kind=kind,
|
||||
)
|
||||
except ValueError as 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}"
|
||||
preview = await gitea_proxy.work_preview(repository, kind, number)
|
||||
if (
|
||||
kind != "issue"
|
||||
or preview.get("repository") != repository
|
||||
or preview.get("kind") != "issue"
|
||||
preview.get("repository") != repository
|
||||
or preview.get("kind") != kind
|
||||
or preview.get("number") != number
|
||||
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()
|
||||
following_item = {
|
||||
"repository": repository,
|
||||
"kind": kind,
|
||||
"number": number,
|
||||
"title": preview.get("title", ""),
|
||||
"state": preview.get("state", ""),
|
||||
|
|
|
|||
|
|
@ -13,25 +13,39 @@ ROOT = Path(__file__).parents[2]
|
|||
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:
|
||||
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.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||
page.add_script_tag(path=FRONTEND / "search-preview.js")
|
||||
page.add_script_tag(path=FRONTEND / "following.js")
|
||||
|
||||
row = page.locator('[data-mobile-queue="following"]')
|
||||
expect(row).to_have_count(1)
|
||||
page.locator("#following-list").evaluate("""node => {
|
||||
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>';
|
||||
expect(row).to_contain_text("Issues and pull requests you watch")
|
||||
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-card")).to_be_visible()
|
||||
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")
|
||||
assert page.locator("#review-following").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_have_text("Stop watching & next")
|
||||
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")
|
||||
assert overflow is False
|
||||
browser.close()
|
||||
|
|
|
|||
|
|
@ -864,7 +864,7 @@ def test_search_preview_preserves_authoritative_watch_and_offers_following_repai
|
|||
const createSearchPreview = require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
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({{
|
||||
fetchJson:async()=>detail,
|
||||
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) == {
|
||||
"status": "watch-partial",
|
||||
"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
|
||||
|
||||
|
||||
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"""
|
||||
require({json.dumps(str(SEARCH_PREVIEW))});
|
||||
(async () => {{
|
||||
|
|
@ -936,7 +936,8 @@ process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,e
|
|||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
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["button"] == {
|
||||
|
|
@ -944,7 +945,7 @@ process.stdout.write(JSON.stringify({{loaded,watching:hydrated.watching,button,e
|
|||
"textContent": "Stop watching",
|
||||
"disabled": False,
|
||||
}
|
||||
assert payload["excluded"] == [True, True]
|
||||
assert payload["excluded"] == [True, False]
|
||||
assert payload["closedFollowing"]["watching"] is True
|
||||
assert payload["retire"] == {
|
||||
"hidden": False,
|
||||
|
|
|
|||
|
|
@ -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-v134" in worker
|
||||
assert "stackchain-dashboard-shell-v135" in worker
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ async def test_confirmed_watch_updates_account_following_collection(monkeypatch,
|
|||
assert following.headers["cache-control"] == "no-store"
|
||||
assert following.json() == {"revision": 1, "degraded": False, "refresh_failures": 0, "items": [{
|
||||
"repository": "stackchain/api",
|
||||
"kind": "issue",
|
||||
"number": 42,
|
||||
"title": "Make mobile review useful",
|
||||
"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
|
||||
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)
|
||||
|
|
@ -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):
|
||||
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"f" * 32)
|
||||
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",
|
||||
"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)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
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"},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
launcher = ROOT / "frontend" / "mobile-queue-launcher.js"
|
||||
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 "getSession:() => followingQueue.session() || commandSearchState" 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 "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-v134" in service_worker
|
||||
assert "stackchain-dashboard-shell-v135" in service_worker
|
||||
|
||||
|
||||
def test_prepare_today_lazily_refreshes_and_directly_reviews_following():
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ def test_confirmed_watch_is_account_scoped_idempotent_and_encrypted(tmp_path):
|
|||
first = 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": []}
|
||||
stored = sqlite3.connect(path).execute(
|
||||
"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)
|
||||
|
||||
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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-v134" in source
|
||||
assert "stackchain-dashboard-shell-v135" 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 pre { max-width:100%; overflow-x:auto;" 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
|
||||
|
|
|
|||
|
|
@ -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-v134" in worker
|
||||
assert "stackchain-dashboard-shell-v135" in worker
|
||||
|
||||
|
||||
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 "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
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-readiness-card" 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():
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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-v134" in service_worker
|
||||
assert "stackchain-dashboard-shell-v135" in service_worker
|
||||
|
||||
|
||||
@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():
|
||||
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-readiness.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():
|
||||
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/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-v134" in source
|
||||
assert "stackchain-dashboard-shell-v135" in source
|
||||
|
||||
|
||||
def test_per_day_week_conflict_ui_rolls_the_offline_shell():
|
||||
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
|
||||
|
||||
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
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/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-v134" in source
|
||||
assert "stackchain-dashboard-shell-v135" 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-v134" in source
|
||||
assert "stackchain-dashboard-shell-v135" 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-v134" in source
|
||||
assert "stackchain-dashboard-shell-v135" 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-v134" in source
|
||||
assert "stackchain-dashboard-shell-v135" 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-v134" in source
|
||||
assert "stackchain-dashboard-shell-v135" 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-v134" in source
|
||||
assert "stackchain-dashboard-shell-v135" 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-v134" in source
|
||||
assert "stackchain-dashboard-shell-v135" 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-v134" in source
|
||||
assert "stackchain-dashboard-shell-v135" 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-v134" in source
|
||||
assert "stackchain-dashboard-shell-v135" 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-v134" in source
|
||||
assert "stackchain-dashboard-shell-v135" 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-v134" in source
|
||||
assert "stackchain-dashboard-shell-v135" 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-v134" in source
|
||||
assert "stackchain-dashboard-shell-v135" 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():
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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-v134" in source
|
||||
assert "stackchain-dashboard-shell-v135" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user