feat: add mobile Following queue for watched issues (Closes #1293)
CI / lint (pull_request) Failing after 4m8s
CI / build-release (pull_request) Has been skipped
CI / browser-journey (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-23 04:04:09 +00:00
1 parent 4b4e55dbe2
commit 041f2dbe9b
17 files changed
+553 -12

No files matched your search

@@ -0,0 +1,30 @@
from pathlib import Path
from playwright.sync_api import expect, sync_playwright
ROOT = Path(__file__).parents[2]
FRONTEND = ROOT / "frontend"
def test_following_queue_is_phone_usable_at_narrow_viewport():
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 320, "height": 568})
page.set_content((FRONTEND / "index.html").read_text())
page.add_style_tag(path=FRONTEND / "dashboard.css")
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" type="button"><span><strong>Quiet issue with a long mobile title</strong><small>stackchain/api #42 · open · just now</small></span><span aria-hidden="true">›</span></button>';
}""")
page.locator("#following-sheet").evaluate("node => node.showModal()")
expect(page.locator("#following-sheet")).to_be_visible()
expect(page.locator(".following-card")).to_be_visible()
assert page.locator(".following-card").bounding_box()["height"] >= 44
assert page.locator("#close-following").bounding_box()["height"] >= 44
overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth")
assert overflow is False
browser.close()
+55
View File
@@ -0,0 +1,55 @@
import httpx
import pytest
from src import main
from src.following_store import FollowingStore
@pytest.mark.anyio
async def test_confirmed_watch_updates_account_following_collection(monkeypatch, tmp_path):
store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"a" * 32)
detail = {
"repository": "stackchain/api",
"kind": "issue",
"number": 42,
"title": "Make mobile review useful",
"state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42",
"claimable": True,
}
async def preview(repository, kind, number):
assert (repository, kind, number) == ("stackchain/api", "issue", 42)
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, raising=False)
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/42/preview/subscription?kind=issue"
)
following = await client.get("/api/v1/following")
assert watched.status_code == 200
assert watched.json() == {"watching": True, "following_revision": 1, "following_count": 1}
assert following.status_code == 200
assert following.headers["cache-control"] == "no-store"
assert following.json() == {"revision": 1, "items": [{
"repository": "stackchain/api",
"number": 42,
"title": "Make mobile review useful",
"state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42",
}]}
+71
View File
@@ -0,0 +1,71 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
MODULE = ROOT / "frontend" / "following.js"
def run(script: str) -> dict:
harness = f"""
const createFollowing = require({json.dumps(str(MODULE))});
const state = {{ renders:[], counts:[], opened:[], requests:[] }};
const feature = createFollowing({{
fetchJson: async path => {{
state.requests.push(path);
return {{revision:3,items:[{{repository:'stackchain/api',number:42,title:'Quiet issue',state:'open',updated_at:'2026-08-23T03:00:00Z',url:'https://forge.example/issue/42'}}]}};
}},
render: snapshot => state.renders.push(snapshot),
onCount: count => state.counts.push(count),
onOpen: item => state.opened.push(item),
}});
(async () => {{ {script} }})().catch(error => {{ console.error(error); process.exit(1); }});
"""
completed = subprocess.run(["node", "-e", harness], text=True, capture_output=True, check=True)
return json.loads(completed.stdout)
def test_following_loads_account_collection_and_opens_existing_preview():
result = run("""
await feature.load();
feature.open(0);
process.stdout.write(JSON.stringify(state));
""")
assert result["requests"] == ["api/v1/following"]
assert result["counts"] == [1]
assert result["renders"][-1]["status"] == "ready"
assert result["renders"][-1]["items"][0]["title"] == "Quiet issue"
assert result["opened"] == [{
"repository": "stackchain/api",
"number": 42,
"title": "Quiet issue",
"state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/issue/42",
"kind": "issue",
}]
def test_following_opens_explicitly_but_never_becomes_work_recommendation():
launcher = ROOT / "frontend" / "mobile-queue-launcher.js"
script = f"""
const createLauncher = require({json.dumps(str(launcher))});
const calls = [];
const feature = createLauncher({{
getCounts:() => ({{following:7}}),
openFollowing:() => {{ calls.push('following'); return 'opened-following'; }},
selectFilter:name => calls.push(name), firstAction:() => null,
announce:() => {{}}, openFindWork:() => calls.push('find'),
}});
process.stdout.write(JSON.stringify({{opened:feature.open('following'),recommended:feature.recommend(),calls}}));
"""
result = json.loads(subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
).stdout)
assert result == {
"opened": "opened-following",
"recommended": {"name": "find", "count": 0, "label": "Find Work"},
"calls": ["following"],
}
+30
View File
@@ -0,0 +1,30 @@
import sqlite3
from src.following_store import FollowingStore
KEY = b"f" * 32
ITEM = {
"repository": "stackchain/api",
"number": 42,
"title": "Make mobile review useful",
"state": "open",
"updated_at": "2026-08-23T03:00:00Z",
"url": "https://forge.example/stackchain/api/issues/42",
}
def test_confirmed_watch_is_account_scoped_idempotent_and_encrypted(tmp_path):
path = tmp_path / "following.sqlite3"
store = FollowingStore(path, encryption_key=KEY, limit=50)
first = store.set_watching("Timmy", ITEM, True)
repeated = store.set_watching("timmy", ITEM, True)
assert first == repeated == {"revision": 1, "items": [ITEM]}
assert store.get("other") == {"revision": 0, "items": []}
stored = sqlite3.connect(path).execute(
"SELECT items FROM following_issues WHERE login = ?", ("timmy",)
).fetchone()[0]
assert ITEM["title"] not in stored
assert ITEM["repository"] not in stored
+15 -3
View File
@@ -251,19 +251,26 @@ async def test_search_preview_subscription_reads_server_truth_without_caching(mo
@pytest.mark.anyio
@pytest.mark.parametrize(("method", "watching"), [("PUT", True), ("DELETE", False)])
async def test_search_preview_subscription_mutation_revalidates_target_and_confirms_truth(
monkeypatch, method, watching
monkeypatch, tmp_path, method, watching
):
calls = []
store = main.FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32)
async def preview(repository, kind, number):
calls.append(("preview", repository, kind, number))
return {"repository": repository, "kind": kind, "number": number,
"state": "open", "claimable": True}
"title": "Quiet issue", "state": "open", "claimable": True,
"updated_at": "2026-08-23T03:00:00Z", "url": "https://forge.example/issue/42"}
async def set_subscription(repository, number, desired):
calls.append(("set", repository, number, desired))
return {"watching": desired}
async def user():
return {"login": "timmy"}
monkeypatch.setattr(main, "_following_store", lambda: store)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "work_preview", preview)
monkeypatch.setattr(main.gitea_proxy, "set_issue_subscription", set_subscription, raising=False)
transport = httpx.ASGITransport(app=main.app)
@@ -275,7 +282,12 @@ async def test_search_preview_subscription_mutation_revalidates_target_and_confi
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {"watching": watching}
expected_count = 1 if watching else 0
assert response.json() == {
"watching": watching,
"following_revision": expected_count,
"following_count": expected_count,
}
assert calls == [
("preview", "stackchain/api", "issue", 42),
("set", "stackchain/api", 42, watching),
+1
View File
@@ -1333,6 +1333,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/commands.js",
"/dashboard/static/saved-searches.js",
"/dashboard/static/search-preview.js",
"/dashboard/static/following.js",
"/dashboard/static/search-reply-draft-store.js",
"/dashboard/static/conversation-reply-draft-store.js",
"/dashboard/static/conversation-photo-drafts.js",