stackchain-dashboard/tests/test_mobile_recent_work.py
timmy 2a06cf1daa
Some checks failed
CI / lint (pull_request) Successful in 3m50s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 1h10m38s
CI / release-candidate (pull_request) Has been cancelled
feat: pin current mobile detail item (Closes #1489)
2026-08-27 23:30:06 +00:00

492 lines
22 KiB
Python

import json
import subprocess
from pathlib import Path
RECENT_WORK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-recent-work.js"
INDEX = Path(__file__).resolve().parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).resolve().parents[1] / "frontend" / "dashboard.css"
def run_node(script: str) -> dict:
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_recent_work_is_account_scoped_deduplicated_and_bounded():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
const values = new Map();
const storage = {{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key, value) => values.set(key, value),
removeItem:key => values.delete(key),
}};
let login = ' Alice ';
const recent = createRecentWork({{storage, getLogin:() => login, limit:5}});
for (let number=1; number<=6; number += 1) {{
recent.record({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
}}
recent.record({{kind:'issue', repository:'stackchain/dashboard', number:3, title:'Issue 3 updated'}});
const alice = recent.items();
login = 'bob';
recent.record({{kind:'pull', repository:'stackchain/api', number:9, title:'Ship API'}});
const bob = recent.items();
login = '';
const anonymousRecord = recent.record({{kind:'issue', repository:'stackchain/dashboard', number:99, title:'Private'}});
const anonymous = recent.items();
process.stdout.write(JSON.stringify({{alice,bob,anonymousRecord,anonymous,keys:Array.from(values.keys()).sort()}}));
"""
payload = run_node(script)
assert [item["number"] for item in payload["alice"]] == [3, 6, 5, 4, 2]
assert payload["alice"][0] == {
"kind": "issue",
"repository": "stackchain/dashboard",
"number": 3,
"title": "Issue 3 updated",
"route": "#/my-work/issue/stackchain/dashboard/3",
}
assert payload["bob"] == [
{
"kind": "pull",
"repository": "stackchain/api",
"number": 9,
"title": "Ship API",
"route": "#/my-work/pull/stackchain/api/9",
}
]
assert payload["anonymousRecord"] is False
assert payload["anonymous"] == []
assert payload["keys"] == [
"stackchain.mobile-recent-work.v1.alice",
"stackchain.mobile-recent-work.v1.bob",
]
def test_recent_work_renders_safe_rows_and_opens_the_selected_route():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
const values = new Map([
['stackchain.mobile-recent-work.v1.alice', JSON.stringify([
{{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Fix mobile queue',route:'#/wrong'}},
{{kind:'update',number:42,title:'Review release status'}},
{{kind:'pull',repository:'bad/repo/extra',number:1,title:'Unsafe'}},
{{kind:'issue',repository:'stackchain/dashboard',number:0,title:'Invalid'}},
])],
]);
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?.();}},
}};
}}
const list=node('div'); const section=node('section'); const opened=[];
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice', document:{{createElement:node}}, list, section,
openRoute:route=>opened.push(route),
}});
const rendered=recent.render();
list.children[1].children[0].click();
process.stdout.write(JSON.stringify({{
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,
pin:row.children[1].attributes['data-recent-work-pin'],
}});}}),opened,
}}));
"""
payload = run_node(script)
assert payload == {
"rendered": 2,
"hidden": False,
"rows": [
{
"label": "Open Fix mobile queue, issue stackchain/dashboard #7",
"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"],
}
def test_recent_work_records_offline_first_then_merges_the_server_snapshot():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
(async()=>{{
const values = new Map(); const calls=[]; const status={{textContent:''}};
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
const remote={{kind:'pull',repository:'stackchain/api',number:9,title:'Ship API',route:'#/my-work/pull/stackchain/api/9'}};
const local={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Fix queue',route:'#/my-work/issue/stackchain/dashboard/7'}};
const recent=createRecentWork({{
storage,getLogin:()=>'alice',status,debounceMs:99999,
fetchJson:async (url, options={{}})=>{{
calls.push([url,options.method||'GET']);
return options.method==='POST' ? {{items:[local,remote]}} : {{items:[remote]}};
}},
}});
recent.record(local);
const immediate={{items:recent.items(),status:status.textContent,state:recent.state()}};
await recent.sync();
process.stdout.write(JSON.stringify({{immediate,settled:recent.items(),status:status.textContent,calls}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
payload = run_node(script)
assert payload["immediate"]["items"][0]["number"] == 7
assert payload["immediate"]["status"] == "Sync pending."
assert payload["immediate"]["state"]["pending"] is True
assert [item["number"] for item in payload["settled"]] == [7, 9]
assert payload["status"] == ""
assert payload["calls"] == [["api/v1/recent-work", "POST"]]
def test_recent_work_drains_a_newer_same_route_generation_after_an_inflight_response():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
(async()=>{{
const values = new Map(); const calls=[];
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
const oldItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Old title'}};
const newItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'New title'}};
let releaseFirst;
const firstResponse=new Promise(resolve=>{{releaseFirst=resolve;}});
const recent=createRecentWork({{
storage,getLogin:()=>'alice',debounceMs:99999,
fetchJson:async (_url, options)=>{{
const sent=JSON.parse(options.body);
calls.push(sent.title);
if (calls.length === 1) return firstResponse;
return {{items:[{{...newItem,route:'#/my-work/issue/stackchain/dashboard/7'}}],pinned:[]}};
}},
}});
recent.record(oldItem);
const syncing=recent.sync();
await Promise.resolve();
recent.record(newItem);
releaseFirst({{items:[{{...oldItem,route:'#/my-work/issue/stackchain/dashboard/7'}}],pinned:[]}});
await syncing;
process.stdout.write(JSON.stringify({{calls,items:recent.items(),state:recent.state()}}));
process.exit(0);
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
payload = run_node(script)
assert payload["calls"] == ["Old title", "New title"]
assert payload["items"][0]["title"] == "New title"
assert payload["state"] == {"pending": False, "pendingCount": 0}
def test_recent_work_retries_a_transient_sync_failure_without_a_lifecycle_event():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
(async()=>{{
const values=new Map(); const timers=[]; let calls=0;
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice',debounceMs:99999,retryMs:25,
setTimeout:(callback,delay)=>{{const timer={{callback,delay,cleared:false}};timers.push(timer);return timer;}},
clearTimeout:timer=>{{timer.cleared=true;}},
fetchJson:async (_url,options)=>{{
calls += 1;
if (calls === 1) throw new Error('temporary outage');
return {{items:[JSON.parse(options.body)],pinned:[]}};
}},
}});
recent.record({{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Keep me'}});
await recent.sync();
const afterFailure={{calls,state:recent.state(),active:timers.filter(timer=>!timer.cleared).map(timer=>timer.delay)}};
const retryTimer=timers.find(timer=>!timer.cleared);
retryTimer?.callback();
await new Promise(resolve=>setImmediate(resolve));
const settled={{calls,state:recent.state(),active:timers.filter(timer=>!timer.cleared).length}};
process.stdout.write(JSON.stringify({{afterFailure,settled}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
payload = run_node(script)
assert payload["afterFailure"] == {
"calls": 1,
"state": {"pending": True, "pendingCount": 1},
"active": [25],
}
assert payload["settled"] == {
"calls": 2,
"state": {"pending": False, "pendingCount": 0},
"active": 0,
}
def test_recent_work_drains_a_newer_pin_generation_after_an_inflight_response():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
(async()=>{{
const values=new Map(); const calls=[];
const oldItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'Old pin'}};
const newItem={{kind:'issue',repository:'stackchain/dashboard',number:7,title:'New pin'}};
const route='#/my-work/issue/stackchain/dashboard/7';
let releaseFirst;
const firstResponse=new Promise(resolve=>{{releaseFirst=resolve;}});
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice',debounceMs:99999,
fetchJson:async (_url,options)=>{{
const sent=JSON.parse(options.body); calls.push(sent.title);
if (calls.length === 1) return firstResponse;
return {{items:[],pinned:[{{...newItem,route}}]}};
}},
}});
recent.pin(oldItem);
const syncing=recent.sync();
await Promise.resolve();
recent.pin(newItem);
releaseFirst({{items:[],pinned:[{{...oldItem,route}}]}});
await syncing;
process.stdout.write(JSON.stringify({{calls,pinned:recent.pinned(),state:recent.state()}}));
process.exit(0);
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
payload = run_node(script)
assert payload["calls"] == ["Old pin", "New pin"]
assert payload["pinned"][0]["title"] == "New pin"
assert payload["state"] == {"pending": False, "pendingCount": 0}
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,
recentRows:recentList.children.length,
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 True
assert payload["immediate"]["pinnedHidden"] is False
assert payload["immediate"]["recentRows"] == 0
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", "POST", payload["immediate"]["pinned"][0]],
[
"api/v1/recent-work/pin",
"DELETE",
{"route": "#/my-work/issue/stackchain/dashboard/1477"},
],
]
def test_current_detail_pin_follows_route_and_toggles_offline_first():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
const values=new Map();
function button() {{ return {{hidden:true,textContent:'',attributes:{{}},listeners:{{}},
setAttribute(name,value){{this.attributes[name]=String(value);}},
addEventListener(name,callback){{this.listeners[name]=callback;}},
click(){{this.listeners.click?.();}},
}}; }}
const detailPins=[button(),button(),button(),button()];
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice',detailPins,debounceMs:99999,
}});
const issue={{kind:'issue',repository:'stackchain/dashboard',number:1489,title:'Pin current detail'}};
const pull={{kind:'pull',repository:'stackchain/api',number:20,title:'Ship API'}};
recent.setCurrent(issue);
const issueReady=detailPins.map(pin=>({{hidden:pin.hidden,text:pin.textContent,
pressed:pin.attributes['aria-pressed'],label:pin.attributes['aria-label'],action:pin.attributes['data-current-work-pin']}}));
detailPins[0].click();
const pinned={{items:recent.pinned(),state:recent.state(),buttons:detailPins.map(pin=>({{text:pin.textContent,
pressed:pin.attributes['aria-pressed'],label:pin.attributes['aria-label'],action:pin.attributes['data-current-work-pin']}}))}};
recent.setCurrent(pull);
const pullReady={{text:detailPins[1].textContent,label:detailPins[1].attributes['aria-label'],pressed:detailPins[1].attributes['aria-pressed']}};
recent.setCurrent(issue);
detailPins[2].click();
const unpinned={{items:recent.pinned(),recent:recent.items(),state:recent.state(),text:detailPins[0].textContent}};
recent.setCurrent(null);
process.stdout.write(JSON.stringify({{issueReady,pinned,pullReady,unpinned,hidden:detailPins.map(pin=>pin.hidden)}}));
"""
payload = run_node(script)
assert payload["issueReady"] == [
{
"hidden": False,
"text": "Pin",
"pressed": "false",
"label": "Pin Pin current detail",
"action": "pin",
}
] * 4
assert payload["pinned"]["items"][0]["route"] == "#/my-work/issue/stackchain/dashboard/1489"
assert payload["pinned"]["state"] == {"pending": True, "pendingCount": 1}
assert payload["pinned"]["buttons"] == [
{
"text": "Pinned",
"pressed": "true",
"label": "Unpin Pin current detail",
"action": "unpin",
}
] * 4
assert payload["pullReady"] == {
"text": "Pin",
"label": "Pin Ship API",
"pressed": "false",
}
assert payload["unpinned"]["items"] == []
assert payload["unpinned"]["recent"] == []
assert payload["unpinned"]["state"] == {"pending": True, "pendingCount": 1}
assert payload["unpinned"]["text"] == "Pin"
assert payload["hidden"] == [True] * 4
def test_pinned_work_is_compact_deduplicated_and_promotes_on_open():
script = f"""
const createRecentWork = require({json.dumps(str(RECENT_WORK))});
const makeItem=number=>({{kind:'issue',repository:'stackchain/dashboard',number,title:'Issue '+number}});
const values=new Map([['stackchain.mobile-recent-work.v1.alice',JSON.stringify({{
items:[1,2,3,4,5].map(makeItem),pinned:[1,2,3,4,5].map(makeItem),pending:[],pinOps:[]
}})]]);
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'), recentSection=node('section');
const pinnedList=node('div'), pinnedSection=node('section'), pinnedToggle=node('button');
const opened=[];
const recent=createRecentWork({{
storage:{{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}},
getLogin:()=>'alice',document:{{createElement:node}},debounceMs:99999,
list:recentList,section:recentSection,pinnedList,pinnedSection,pinnedToggle,
openRoute:route=>opened.push(route),
}});
recent.render();
const collapsed={{pinnedRows:pinnedList.children.length,recentRows:recentList.children.length,
recentHidden:recentSection.hidden,toggleHidden:pinnedToggle.hidden,toggleText:pinnedToggle.textContent,
expanded:pinnedToggle.attributes['aria-expanded'],controls:pinnedToggle.attributes['aria-controls']}};
pinnedList.children[2].children[0].click();
const promoted=recent.pinned().map(item=>item.number);
pinnedToggle.click();
const expanded={{pinnedRows:pinnedList.children.length,toggleText:pinnedToggle.textContent,
ariaExpanded:pinnedToggle.attributes['aria-expanded']}};
process.stdout.write(JSON.stringify({{collapsed,promoted,expanded,opened,state:recent.state()}}));
"""
payload = run_node(script)
assert payload["collapsed"] == {
"pinnedRows": 3,
"recentRows": 0,
"recentHidden": True,
"toggleHidden": False,
"toggleText": "Show all 5",
"expanded": "false",
"controls": "mobile-pinned-work-list",
}
assert payload["promoted"] == [3, 1, 2, 4, 5]
assert payload["expanded"] == {
"pinnedRows": 5,
"toggleText": "Show fewer",
"ariaExpanded": "true",
}
assert payload["opened"] == ["#/my-work/issue/stackchain/dashboard/3"]
assert payload["state"] == {"pending": True, "pendingCount": 1}
def test_mobile_queues_integrates_recent_work_with_canonical_detail_routes():
html = INDEX.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
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-pinned-work-toggle"' in html
assert 'aria-controls="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 "pinnedToggle:qs('#mobile-pinned-work-toggle')" 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 html.count('data-current-work-pin') == 4
assert "detailPins:qsa('[data-current-work-pin]')" in dashboard
assert "mobileRecentWork.setCurrent(item)" in dashboard
assert "mobileRecentWork.setCurrent(null)" in dashboard
assert "[data-recent-work-route]" in css
assert "[data-recent-work-pin]" in css
assert "[data-current-work-pin]" in css
assert "min-height:44px" in css
assert "min-width:0" in css