feat: pin frequent mobile work (Closes #1477)
This commit is contained in:
1 parent
2b630d3fa8
commit
94bd2cafba
11 files changed
+527
-75
No files matched your search
@@ -0,0 +1,72 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
|
||||
pytest.skip("packaged pinned-work journey runs only in its gated CI job", allow_module_level=True)
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("viewport", [
|
||||
{"width": 320, "height": 568},
|
||||
{"width": 390, "height": 844},
|
||||
])
|
||||
def test_operator_pins_and_reopens_frequent_work_without_phone_overflow(viewport):
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
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 / "mobile-recent-work.js")
|
||||
page.evaluate("""() => {
|
||||
const values = new Map();
|
||||
const storage = {
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key, value) => values.set(key, value),
|
||||
};
|
||||
const item = {
|
||||
kind:'issue', repository:'stackchain/stackchain-dashboard', number:1477,
|
||||
title:'Pin frequent work across signed-in mobile devices',
|
||||
};
|
||||
window.opened = [];
|
||||
window.recentWork = createMobileRecentWork({
|
||||
storage, getLogin:() => 'timmy', document,
|
||||
section:document.querySelector('#mobile-recent-work'),
|
||||
list:document.querySelector('#mobile-recent-work-list'),
|
||||
pinnedSection:document.querySelector('#mobile-pinned-work'),
|
||||
pinnedList:document.querySelector('#mobile-pinned-work-list'),
|
||||
status:document.querySelector('#mobile-recent-work-status'),
|
||||
openRoute:route => window.opened.push(route),
|
||||
});
|
||||
window.recentWork.record(item);
|
||||
window.recentWork.pin(item);
|
||||
document.querySelector('#mobile-queue-sheet').showModal();
|
||||
}""")
|
||||
|
||||
expect(page.locator("#mobile-pinned-work")).to_be_visible()
|
||||
expect(page.locator("#mobile-recent-work")).to_be_visible()
|
||||
expect(page.locator("#mobile-recent-work-status")).to_have_text("Sync pending.")
|
||||
open_button = page.get_by_role(
|
||||
"button", name="Open Pin frequent work across signed-in mobile devices, issue stackchain/stackchain-dashboard #1477"
|
||||
).first
|
||||
pin_button = page.get_by_role(
|
||||
"button", name="Unpin Pin frequent work across signed-in mobile devices"
|
||||
)
|
||||
for control in (open_button, pin_button):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert bounds["x"] >= 0 and bounds["x"] + bounds["width"] <= viewport["width"]
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
||||
open_button.focus()
|
||||
open_button.press("Enter")
|
||||
assert page.evaluate("window.opened") == [
|
||||
"#/my-work/issue/stackchain/stackchain-dashboard/1477"
|
||||
]
|
||||
browser.close()
|
||||
@@ -93,13 +93,14 @@ const recent=createRecentWork({{
|
||||
openRoute:route=>opened.push(route),
|
||||
}});
|
||||
const rendered=recent.render();
|
||||
list.children[1].click();
|
||||
list.children[1].children[0].click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
rendered,hidden:section.hidden,rows:list.children.map(button=>({{
|
||||
rendered,hidden:section.hidden,rows:list.children.map(row=>{{const button=row.children[0];return ({{
|
||||
label:button.attributes['aria-label'],route:button.attributes['data-recent-work-route'],
|
||||
primary:button.children[0].children[0].textContent,
|
||||
secondary:button.children[0].children[1].textContent,
|
||||
}})),opened,
|
||||
pin:row.children[1].attributes['data-recent-work-pin'],
|
||||
}});}}),opened,
|
||||
}}));
|
||||
"""
|
||||
payload = run_node(script)
|
||||
@@ -113,12 +114,14 @@ process.stdout.write(JSON.stringify({{
|
||||
"route": "#/my-work/issue/stackchain/dashboard/7",
|
||||
"primary": "Fix mobile queue",
|
||||
"secondary": "Issue · stackchain/dashboard #7",
|
||||
"pin": "pin",
|
||||
},
|
||||
{
|
||||
"label": "Open Review release status, update #42",
|
||||
"route": "#/my-work/update/42",
|
||||
"primary": "Review release status",
|
||||
"secondary": "Update · #42",
|
||||
"pin": "pin",
|
||||
},
|
||||
],
|
||||
"opened": ["#/my-work/update/42"],
|
||||
@@ -156,6 +159,73 @@ process.stdout.write(JSON.stringify({{immediate,settled:recent.items(),status:st
|
||||
assert payload["calls"] == [["api/v1/recent-work", "POST"]]
|
||||
|
||||
|
||||
def test_recent_work_pins_offline_first_syncs_and_renders_separate_touch_actions():
|
||||
script = f"""
|
||||
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
|
||||
(async()=>{{
|
||||
const item={{kind:'issue',repository:'stackchain/dashboard',number:1477,title:'Pin frequent work',route:'#/my-work/issue/stackchain/dashboard/1477'}};
|
||||
const storageKey='stackchain.mobile-recent-work.v1.alice';
|
||||
const values=new Map([[storageKey,JSON.stringify({{items:[item],pinned:[],pending:[],pinOps:[]}})]]);
|
||||
const status={{textContent:''}}; const calls=[]; const opened=[];
|
||||
function node(tag) {{
|
||||
return {{tag,children:[],attributes:{{}},listeners:{{}},hidden:false,textContent:'',
|
||||
appendChild(child){{this.children.push(child);return child;}},
|
||||
replaceChildren(...children){{this.children=children;}},
|
||||
setAttribute(name,value){{this.attributes[name]=String(value);}},
|
||||
addEventListener(name,callback){{this.listeners[name]=callback;}},
|
||||
click(){{this.listeners.click?.();}}, focus(){{this.focused=true;}},
|
||||
}};
|
||||
}}
|
||||
const recentList=node('div'); const recentSection=node('section');
|
||||
const pinnedList=node('div'); const pinnedSection=node('section');
|
||||
let remote={{items:[item],pinned:[]}};
|
||||
const recent=createRecentWork({{
|
||||
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
|
||||
getLogin:()=>'alice',status,debounceMs:99999,document:{{createElement:node}},
|
||||
list:recentList,section:recentSection,pinnedList,pinnedSection,
|
||||
openRoute:route=>opened.push(route),
|
||||
fetchJson:async (url,options={{}})=>{{
|
||||
calls.push([url,options.method||'GET',JSON.parse(options.body||'null')]);
|
||||
if (options.method==='PUT') remote={{items:[item],pinned:[item]}};
|
||||
if (options.method==='DELETE') remote={{items:[item],pinned:[]}};
|
||||
return remote;
|
||||
}},
|
||||
}});
|
||||
const pinnedImmediately=recent.pin(item);
|
||||
recent.render();
|
||||
const immediate={{pinned:recent.pinned(),status:status.textContent,recentHidden:recentSection.hidden,pinnedHidden:pinnedSection.hidden,
|
||||
recentActions:recentList.children[0].children.map(child=>child.attributes),
|
||||
pinnedActions:pinnedList.children[0].children.map(child=>child.attributes)}};
|
||||
await recent.sync();
|
||||
pinnedList.children[0].children[0].click();
|
||||
const unpinnedImmediately=recent.unpin(item.route);
|
||||
await recent.sync();
|
||||
process.stdout.write(JSON.stringify({{pinnedImmediately,immediate,opened,unpinnedImmediately,settled:recent.pinned(),calls}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
payload = run_node(script)
|
||||
|
||||
assert payload["pinnedImmediately"] is True
|
||||
assert payload["immediate"]["pinned"][0]["number"] == 1477
|
||||
assert payload["immediate"]["status"] == "Sync pending."
|
||||
assert payload["immediate"]["recentHidden"] is False
|
||||
assert payload["immediate"]["pinnedHidden"] is False
|
||||
assert payload["immediate"]["recentActions"][0]["data-recent-work-route"].endswith("/1477")
|
||||
assert payload["immediate"]["recentActions"][1]["data-recent-work-pin"] == "pin"
|
||||
assert payload["immediate"]["pinnedActions"][1]["data-recent-work-pin"] == "unpin"
|
||||
assert payload["opened"] == ["#/my-work/issue/stackchain/dashboard/1477"]
|
||||
assert payload["unpinnedImmediately"] is True
|
||||
assert payload["settled"] == []
|
||||
assert payload["calls"] == [
|
||||
["api/v1/recent-work/pin", "PUT", payload["immediate"]["pinned"][0]],
|
||||
[
|
||||
"api/v1/recent-work/pin",
|
||||
"DELETE",
|
||||
{"route": "#/my-work/issue/stackchain/dashboard/1477"},
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
|
||||
html = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
@@ -163,15 +233,21 @@ def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
|
||||
|
||||
assert 'id="mobile-recent-work"' in html
|
||||
assert 'id="mobile-recent-work-list"' in html
|
||||
assert 'id="mobile-pinned-work"' in html
|
||||
assert 'id="mobile-pinned-work-list"' in html
|
||||
assert 'id="mobile-recent-work-status" role="status" aria-live="polite"' in html
|
||||
assert '<script src="static/mobile-recent-work.js"></script>' in html
|
||||
assert "createMobileRecentWork({" in dashboard
|
||||
assert "fetchJson:fetchReviewJson" in dashboard
|
||||
assert "status:qs('#mobile-recent-work-status')" in dashboard
|
||||
assert "pinnedList:qs('#mobile-pinned-work-list')" in dashboard
|
||||
assert "pinnedSection:qs('#mobile-pinned-work')" in dashboard
|
||||
assert "mobileRecentWork.startLifecycle({window, document})" in dashboard
|
||||
assert "void mobileRecentWork.load();" in dashboard
|
||||
assert "mobileRecentWork.record(item)" in dashboard
|
||||
assert "mobileRecentWork.render()" in dashboard
|
||||
assert "workRoute.sync()" in dashboard
|
||||
assert "[data-recent-work-route]" in css
|
||||
assert "min-height:56px" in css
|
||||
assert "[data-recent-work-pin]" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "min-width:0" in css
|
||||
@@ -47,7 +47,60 @@ async def test_recent_work_api_is_authenticated_csrf_protected_no_store_and_acco
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert saved.status_code == 200
|
||||
assert saved.json() == {"items": [entry]}
|
||||
assert saved.json() == {"items": [entry], "pinned": []}
|
||||
assert fetched.json() == saved.json()
|
||||
assert fetched.headers["cache-control"] == "no-store"
|
||||
assert other_account.json() == {"items": []}
|
||||
assert other_account.json() == {"items": [], "pinned": []}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_recent_work_pin_api_is_csrf_protected_and_account_scoped(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||
"a-separate-session-signing-secret-with-enough-entropy",
|
||||
)
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_RECENT_WORK_DB", str(tmp_path / "recent-work.sqlite3"))
|
||||
active_login = "Timmy"
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": active_login}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
entry = {
|
||||
"kind": "pull",
|
||||
"repository": "stackchain/dashboard",
|
||||
"number": 1476,
|
||||
"title": "Sync recent work",
|
||||
"route": "#/my-work/pull/stackchain/dashboard/1476",
|
||||
}
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
forbidden = await client.put("/api/v1/recent-work/pin", json=entry)
|
||||
headers = {
|
||||
"Origin": "https://test",
|
||||
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||
}
|
||||
pinned = await client.put("/api/v1/recent-work/pin", json=entry, headers=headers)
|
||||
active_login = "Alexander"
|
||||
isolated = await client.get("/api/v1/recent-work")
|
||||
active_login = "Timmy"
|
||||
unpinned = await client.request(
|
||||
"DELETE",
|
||||
"/api/v1/recent-work/pin",
|
||||
json={"route": entry["route"]},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert forbidden.status_code == 403
|
||||
assert pinned.status_code == 200
|
||||
assert pinned.json() == {"items": [], "pinned": [entry]}
|
||||
assert isolated.json() == {"items": [], "pinned": []}
|
||||
assert unpinned.status_code == 200
|
||||
assert unpinned.json() == {"items": [], "pinned": []}
|
||||
@@ -23,7 +23,7 @@ def test_recent_work_is_encrypted_account_scoped_deduplicated_and_bounded(tmp_pa
|
||||
|
||||
assert [entry["number"] for entry in expected["items"]] == [3, 6, 5, 4, 2]
|
||||
assert RecentWorkStore(database, encryption_key=b"r" * 32).get("timmy") == expected
|
||||
assert store.get("alexander") == {"items": []}
|
||||
assert store.get("alexander") == {"items": [], "pinned": []}
|
||||
with sqlite3.connect(database) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT items FROM recent_work WHERE login = 'timmy'"
|
||||
@@ -33,6 +33,33 @@ def test_recent_work_is_encrypted_account_scoped_deduplicated_and_bounded(tmp_pa
|
||||
assert "#/my-work/issue" not in payload
|
||||
|
||||
|
||||
def test_pinned_work_survives_recent_limit_and_unpin_keeps_recent_item(tmp_path):
|
||||
database = tmp_path / "recent-work.sqlite3"
|
||||
store = RecentWorkStore(database, encryption_key=b"r" * 32, limit=5)
|
||||
|
||||
store.record("timmy", item(1))
|
||||
pinned = store.pin("timmy", item(1))
|
||||
for number in range(2, 8):
|
||||
store.record("timmy", item(number))
|
||||
|
||||
assert pinned["pinned"] == [item(1)]
|
||||
assert [entry["number"] for entry in store.get("timmy")["items"]] == [7, 6, 5, 4, 3]
|
||||
assert store.get("timmy")["pinned"] == [item(1)]
|
||||
assert store.get("alexander") == {"items": [], "pinned": []}
|
||||
|
||||
store.record("timmy", item(1, title="Issue 1 current"))
|
||||
unpinned = store.unpin("timmy", item(1)["route"])
|
||||
|
||||
assert unpinned["pinned"] == []
|
||||
assert unpinned["items"][0] == item(1, title="Issue 1 current")
|
||||
with sqlite3.connect(database) as connection:
|
||||
payload = connection.execute(
|
||||
"SELECT items FROM recent_work WHERE login = 'timmy'"
|
||||
).fetchone()[0]
|
||||
assert "Issue 1" not in payload
|
||||
assert "#/my-work/issue" not in payload
|
||||
|
||||
|
||||
def test_recent_work_rejects_noncanonical_or_unsupported_items(tmp_path):
|
||||
store = RecentWorkStore(tmp_path / "recent-work.sqlite3", encryption_key=b"r" * 32)
|
||||
|
||||
|
||||
Reference in new issue
Block a user