458 lines
22 KiB
Python
458 lines
22 KiB
Python
import json
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
MODULE = Path(__file__).parents[1] / "frontend" / "progressive-my-work.js"
|
|
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
|
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
|
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
|
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
|
|
|
|
|
def test_progressive_my_work_renders_and_filters_assigned_work_before_full_workspace():
|
|
harness = f"""
|
|
const fs=require('fs'); const vm=require('vm');
|
|
const buttons=[
|
|
{{dataset:{{workFilter:'all'}},attrs:{{}},addEventListener(n,cb){{this.cb=cb;}},setAttribute(n,v){{this.attrs[n]=v;}}}},
|
|
{{dataset:{{workFilter:'issue'}},attrs:{{}},addEventListener(n,cb){{this.cb=cb;}},setAttribute(n,v){{this.attrs[n]=v;}}}},
|
|
];
|
|
const list={{innerHTML:''}}; const status={{textContent:''}};
|
|
const document={{querySelector:s=>s==='#my-work-list'?list:s==='#my-work-status'?status:null,querySelectorAll:()=>buttons}};
|
|
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
|
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
|
|
const createProgressiveMyWork=context.module.exports;
|
|
const flow=createProgressiveMyWork({{document,fetchSnapshot:async()=>({{
|
|
user:{{login:'timmy'}},issues:[{{number:7,title:'Fix mobile queue',repository:'stackchain/dashboard',assignees:['timmy'],url:'https://forge.example/issues/7'}}],pull_requests:[]
|
|
}})}});
|
|
(async()=>{{
|
|
await flow.start(); const rendered=list.innerHTML;
|
|
buttons[1].cb();
|
|
console.log(JSON.stringify({{status:status.textContent,rendered,pressed:buttons.map(b=>b.attrs['aria-pressed'])}}));
|
|
}})().catch(e=>{{console.error(e);process.exit(1);}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
|
state = json.loads(result.stdout)
|
|
assert state["status"] == "1 assigned work item ready."
|
|
assert "Fix mobile queue" in state["rendered"]
|
|
assert 'data-progressive-work-index="0"' in state["rendered"]
|
|
assert "href=" not in state["rendered"]
|
|
assert state["pressed"] == ["false", "true"]
|
|
|
|
|
|
def test_progressive_my_work_keeps_supported_queues_truthful_while_planning_loads():
|
|
harness = f"""
|
|
const fs=require('fs'); const vm=require('vm');
|
|
const names=['all','today','agenda','attention','filed','authored','issue','pull','review','update','later','draft'];
|
|
const counts=Object.fromEntries(names.map(name=>[name,{{textContent:'0'}}]));
|
|
const buttons=names.map(name=>({{
|
|
dataset:{{workFilter:name}},attrs:{{}},
|
|
addEventListener(_name,cb){{this.cb=cb;}},
|
|
removeEventListener(){{}},
|
|
setAttribute(key,value){{this.attrs[key]=value;}},
|
|
}}));
|
|
const list={{innerHTML:''}}; const status={{textContent:''}};
|
|
const document={{
|
|
querySelector(selector){{
|
|
if(selector==='#my-work-list')return list;
|
|
if(selector==='#my-work-status')return status;
|
|
const match=selector.match(/^\\[data-work-count="(.+)"\\]$/);
|
|
return match?counts[match[1]]:null;
|
|
}},
|
|
querySelectorAll:()=>buttons,
|
|
}};
|
|
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
|
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
|
|
const flow=context.module.exports({{document,fetchSnapshot:async()=>({{
|
|
user:{{login:'timmy'}},
|
|
issues:[{{number:7,title:'Filed from phone',repository:'stackchain/dashboard',assignees:[],work_reasons:['created_by_me'],url:'https://forge.example/issues/7'}}],
|
|
pull_requests:[{{number:8,title:'Authored release',repository:'stackchain/dashboard',assignees:[],work_reasons:['authored_by_me'],url:'https://forge.example/pulls/8'}}],
|
|
}})}});
|
|
(async()=>{{
|
|
await flow.start();
|
|
buttons[names.indexOf('filed')].cb(); const filed=list.innerHTML;
|
|
buttons[names.indexOf('authored')].cb(); const authored=list.innerHTML;
|
|
buttons[names.indexOf('today')].cb(); const today=list.innerHTML;
|
|
console.log(JSON.stringify({{filed,authored,today,counts:Object.fromEntries(names.map(name=>[name,counts[name].textContent]))}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
|
state = json.loads(result.stdout)
|
|
assert "Filed from phone" in state["filed"]
|
|
assert "Authored release" in state["authored"]
|
|
assert "Today is still loading" in state["today"]
|
|
assert "No work" not in state["today"]
|
|
assert state["counts"] == {
|
|
"all": "2",
|
|
"today": "0",
|
|
"agenda": "0",
|
|
"attention": "0",
|
|
"filed": "1",
|
|
"authored": "1",
|
|
"issue": "1",
|
|
"pull": "1",
|
|
"review": "0",
|
|
"update": "0",
|
|
"later": "0",
|
|
"draft": "0",
|
|
}
|
|
|
|
|
|
def test_progressive_queue_selection_is_handed_to_the_hydrated_workspace():
|
|
harness = f"""
|
|
const fs=require('fs'); const vm=require('vm');
|
|
const buttons=['all','filed'].map(name=>({{
|
|
dataset:{{workFilter:name}},attrs:{{}},removed:false,
|
|
addEventListener(_name,cb){{this.cb=cb;}},
|
|
removeEventListener(_name,cb){{if(cb===this.cb)this.removed=true;}},
|
|
setAttribute(key,value){{this.attrs[key]=value;}},
|
|
}}));
|
|
const document={{
|
|
querySelector:selector=>selector==='#my-work-list'?{{innerHTML:''}}:selector==='#my-work-status'?{{textContent:''}}:null,
|
|
querySelectorAll:()=>buttons,
|
|
}};
|
|
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
|
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
|
|
const flow=context.module.exports({{document,fetchSnapshot:async()=>({{user:{{login:'timmy'}},issues:[],pull_requests:[]}})}});
|
|
(async()=>{{
|
|
await flow.start(); buttons[1].cb();
|
|
const before=flow.handoff(); flow.stop(); const after=flow.handoff();
|
|
console.log(JSON.stringify({{before,after,removed:buttons.map(button=>button.removed)}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
|
state = json.loads(result.stdout)
|
|
assert state == {
|
|
"before": {"selectedFilter": "filed"},
|
|
"after": {"selectedFilter": "filed"},
|
|
"removed": [True, True],
|
|
}
|
|
|
|
dashboard = DASHBOARD.read_text()
|
|
assert "const progressiveWorkHandoff = window.stackchainProgressiveMyWork?.handoff?.();" in dashboard
|
|
assert "progressiveWorkHandoff?.selectedFilter" in dashboard
|
|
|
|
|
|
def test_progressive_live_snapshot_is_handed_to_the_workspace_exactly_once():
|
|
harness = f"""
|
|
const fs=require('fs'); const vm=require('vm');
|
|
const document={{
|
|
querySelector:selector=>selector==='#my-work-list'?{{innerHTML:''}}:selector==='#my-work-status'?{{textContent:''}}:null,
|
|
querySelectorAll:()=>[],
|
|
}};
|
|
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
|
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
|
|
const snapshot={{
|
|
context:{{user:{{login:'timmy'}},issues:[],pull_requests:[]}},
|
|
events:[{{id:7}}], notifications:[],
|
|
revisions:{{context:'0123456789abcdef.1',events:'fedcba9876543210.2',notifications:'0011223344556677.3'}},
|
|
freshness:{{fresh_for_seconds:8,sections:{{
|
|
context:{{degraded:false,age_seconds:2}},events:{{degraded:false,age_seconds:2}},notifications:{{degraded:false,age_seconds:2}},
|
|
}}}},
|
|
}};
|
|
const flow=context.module.exports({{document,fetchSnapshot:async()=>snapshot}});
|
|
(async()=>{{
|
|
await flow.start();
|
|
const first=flow.handoff(); const second=flow.handoff();
|
|
console.log(JSON.stringify({{
|
|
firstSnapshot:first.liveSnapshot,
|
|
secondHasSnapshot:Object.prototype.hasOwnProperty.call(second,'liveSnapshot'),
|
|
}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
|
state = json.loads(result.stdout)
|
|
|
|
assert state == {"firstSnapshot": {
|
|
"context": {"user": {"login": "timmy"}, "issues": [], "pull_requests": []},
|
|
"events": [{"id": 7}],
|
|
"notifications": [],
|
|
"revisions": {
|
|
"context": "0123456789abcdef.1",
|
|
"events": "fedcba9876543210.2",
|
|
"notifications": "0011223344556677.3",
|
|
},
|
|
"freshness": {
|
|
"fresh_for_seconds": 8,
|
|
"sections": {
|
|
"context": {"degraded": False, "age_seconds": 2},
|
|
"events": {"degraded": False, "age_seconds": 2},
|
|
"notifications": {"degraded": False, "age_seconds": 2},
|
|
},
|
|
},
|
|
}, "secondHasSnapshot": False}
|
|
|
|
|
|
def test_progressive_inflight_snapshot_survives_workspace_handoff_and_stop():
|
|
harness = f"""
|
|
const fs=require('fs'); const vm=require('vm');
|
|
const document={{
|
|
querySelector:selector=>selector==='#my-work-list'?{{innerHTML:''}}:selector==='#my-work-status'?{{textContent:''}}:null,
|
|
querySelectorAll:()=>[],
|
|
}};
|
|
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
|
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
|
|
let resolveSnapshot;
|
|
const snapshot={{context:{{user:{{login:'timmy'}},issues:[],pull_requests:[]}},events:[],notifications:[]}};
|
|
const flow=context.module.exports({{document,fetchSnapshot:()=>new Promise(resolve=>{{resolveSnapshot=resolve;}})}});
|
|
(async()=>{{
|
|
const started=flow.start();
|
|
await Promise.resolve();
|
|
const handoff=flow.handoff();
|
|
flow.stop();
|
|
resolveSnapshot(snapshot);
|
|
const transferred=await handoff.liveSnapshotPromise;
|
|
await started;
|
|
console.log(JSON.stringify({{transferred}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
|
|
|
assert json.loads(result.stdout) == {"transferred": {
|
|
"context": {"user": {"login": "timmy"}, "issues": [], "pull_requests": []},
|
|
"events": [],
|
|
"notifications": [],
|
|
}}
|
|
|
|
|
|
def test_progressive_capture_can_read_only_the_confirmed_snapshot_login():
|
|
harness = f"""
|
|
const fs=require('fs'); const vm=require('vm');
|
|
const document={{
|
|
querySelector:selector=>selector==='#my-work-list'?{{innerHTML:''}}:selector==='#my-work-status'?{{textContent:''}}:null,
|
|
querySelectorAll:()=>[],
|
|
}};
|
|
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
|
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
|
|
const flow=context.module.exports({{document,fetchSnapshot:async()=>({{
|
|
context:{{user:{{login:'timmy'}},issues:[],pull_requests:[]}}, events:[], notifications:[],
|
|
}})}});
|
|
(async()=>{{
|
|
const before=flow.login(); await flow.start(); const after=flow.login(); flow.stop();
|
|
console.log(JSON.stringify({{before,after,stopped:flow.login()}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
|
|
|
assert json.loads(result.stdout) == {"before": "", "after": "timmy", "stopped": "timmy"}
|
|
|
|
|
|
def test_progressive_my_work_retries_a_failed_snapshot_without_reloading():
|
|
harness = f"""
|
|
const fs=require('fs'); const vm=require('vm');
|
|
const list={{innerHTML:''}}; const status={{textContent:''}};
|
|
const document={{
|
|
querySelector:selector=>selector==='#my-work-list'?list:selector==='#my-work-status'?status:null,
|
|
querySelectorAll:()=>[],
|
|
}};
|
|
let timer=null; let attempts=0;
|
|
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,URLSearchParams,AbortController,document}};
|
|
vm.createContext(context);
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
|
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(Path(__file__).parents[1] / 'frontend' / 'context-poller.js'))},'utf8'),context);
|
|
context.createContextPoller=context.module.exports; context.module={{exports:{{}}}};
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
|
|
const flow=context.module.exports({{
|
|
document,
|
|
fetchSnapshot:async()=>{{
|
|
attempts += 1;
|
|
if(attempts===1) throw new Error('temporary outage');
|
|
return {{context:{{user:{{login:'timmy'}},issues:[{{
|
|
number:9,title:'Recovered assignment',repository:'stackchain/dashboard',
|
|
assignees:['timmy'],url:'https://forge.example/issues/9',
|
|
}}],pull_requests:[]}},events:[],notifications:[]}};
|
|
}},
|
|
pollerOptions:{{
|
|
setTimer:callback=>{{timer=callback; return 1;}}, clearTimer:()=>{{timer=null;}},
|
|
setDeadlineTimer:()=>2, clearDeadlineTimer:()=>{{}}, intervalMs:5,
|
|
}},
|
|
}});
|
|
(async()=>{{
|
|
const first=await flow.start();
|
|
const reconnecting=status.textContent;
|
|
if(!timer) throw new Error('retry was not scheduled');
|
|
timer(); await Promise.resolve(); await Promise.resolve(); await new Promise(resolve=>setImmediate(resolve));
|
|
console.log(JSON.stringify({{first,reconnecting,attempts,status:status.textContent,html:list.innerHTML}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
|
|
|
assert json.loads(result.stdout) == {
|
|
"first": False,
|
|
"reconnecting": "Assigned work is reconnecting…",
|
|
"attempts": 2,
|
|
"status": "1 assigned work item ready.",
|
|
"html": '<article class="my-work-card progressive-my-work-card"><button class="my-work-card-main" type="button" data-progressive-work-index="0"><strong>Recovered assignment</strong><span class="small">stackchain/dashboard#9 · Assigned to you</span></button></article>',
|
|
}
|
|
|
|
|
|
def test_progressive_my_work_refreshes_with_revisions_and_coalesces_lifecycle_recovery():
|
|
harness = f"""
|
|
const fs=require('fs'); const vm=require('vm');
|
|
const list={{innerHTML:''}}; const status={{textContent:''}};
|
|
const document={{
|
|
hidden:false,
|
|
querySelector:selector=>selector==='#my-work-list'?list:selector==='#my-work-status'?status:null,
|
|
querySelectorAll:()=>[],
|
|
}};
|
|
const handlers={{}}; const removed=[];
|
|
const lifecycleTarget={{
|
|
addEventListener:(name,callback)=>{{handlers[name]=callback;}},
|
|
removeEventListener:(name,callback)=>{{if(handlers[name]===callback){{removed.push(name);delete handlers[name];}}}},
|
|
}};
|
|
let timer=null; let calls=[]; let resolveRefresh;
|
|
const initial={{
|
|
context:{{user:{{login:'timmy'}},issues:[],pull_requests:[]}},events:[],notifications:[],
|
|
revisions:{{context:'0123456789abcdef.1',events:'fedcba9876543210.2',notifications:'0011223344556677.3'}},
|
|
freshness:{{fresh_for_seconds:8,sections:{{context:{{degraded:false,age_seconds:2}},events:{{degraded:false,age_seconds:2}},notifications:{{degraded:false,age_seconds:2}}}}}},
|
|
}};
|
|
const updated={{...initial,context:{{user:{{login:'timmy'}},issues:[{{number:10,title:'New assignment',repository:'stackchain/dashboard',assignees:['timmy']}}],pull_requests:[]}}}};
|
|
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,URLSearchParams,AbortController,document}};
|
|
vm.createContext(context);
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
|
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(Path(__file__).parents[1] / 'frontend' / 'context-poller.js'))},'utf8'),context);
|
|
context.createContextPoller=context.module.exports; context.module={{exports:{{}}}};
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
|
|
const flow=context.module.exports({{
|
|
document,lifecycleTarget,
|
|
fetchSnapshot:(revisions,options)=>{{
|
|
calls.push({{revisions,hasSignal:Boolean(options.signal)}});
|
|
if(calls.length===1)return Promise.resolve(initial);
|
|
return new Promise(resolve=>{{resolveRefresh=()=>resolve(updated);}});
|
|
}},
|
|
pollerOptions:{{
|
|
setTimer:callback=>{{timer=callback;return 1;}},clearTimer:()=>{{timer=null;}},
|
|
setDeadlineTimer:()=>2,clearDeadlineTimer:()=>{{}},intervalMs:5,
|
|
}},
|
|
}});
|
|
(async()=>{{
|
|
await flow.start(); const freshnessTimer=timer; freshnessTimer();
|
|
handlers.online(); handlers.visibilitychange();
|
|
await Promise.resolve();
|
|
const callsWhilePending=calls.length;
|
|
resolveRefresh(); await Promise.resolve(); await new Promise(resolve=>setImmediate(resolve));
|
|
const handoff=flow.handoff(); flow.stop();
|
|
console.log(JSON.stringify({{
|
|
callsWhilePending,calls,latestTitle:handoff.liveSnapshot.context.issues[0].title,
|
|
removed:removed.sort(),status:status.textContent,
|
|
}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
|
state = json.loads(result.stdout)
|
|
|
|
assert state["callsWhilePending"] == 2
|
|
assert state["calls"][0] == {"revisions": {}, "hasSignal": True}
|
|
assert state["calls"][1] == {
|
|
"revisions": {
|
|
"context": "0123456789abcdef.1",
|
|
"events": "fedcba9876543210.2",
|
|
"notifications": "0011223344556677.3",
|
|
},
|
|
"hasSignal": True,
|
|
}
|
|
assert state["latestTitle"] == "New assignment"
|
|
assert state["removed"] == ["keydown", "online", "visibilitychange"]
|
|
assert state["status"] == "1 assigned work item ready."
|
|
|
|
|
|
def test_progressive_card_opens_safe_in_app_detail_and_hands_it_off_once():
|
|
harness = f"""
|
|
const fs=require('fs'); const vm=require('vm');
|
|
function element(extra={{}}) {{
|
|
return Object.assign({{
|
|
hidden:false,textContent:'',attrs:{{}},listeners:{{}},focusCount:0,
|
|
addEventListener(name,cb){{this.listeners[name]=cb;}},
|
|
removeEventListener(name,cb){{if(this.listeners[name]===cb)delete this.listeners[name];}},
|
|
setAttribute(name,value){{this.attrs[name]=String(value);}},
|
|
removeAttribute(name){{delete this.attrs[name];}},
|
|
focus(){{this.focusCount += 1;}},
|
|
}},extra);
|
|
}}
|
|
const list=element({{innerHTML:''}}); const status=element();
|
|
const sheet=element({{hidden:true}}); const title=element(); const meta=element(); const reason=element();
|
|
const close=element(); const gitea=element({{hidden:true,href:''}});
|
|
const elements={{
|
|
'#my-work-list':list,'#my-work-status':status,'#progressive-work-detail':sheet,
|
|
'#progressive-work-detail-title':title,'#progressive-work-detail-meta':meta,
|
|
'#progressive-work-detail-reason':reason,'#close-progressive-work-detail':close,
|
|
'#open-progressive-work-gitea':gitea,
|
|
}};
|
|
const document={{querySelector:s=>elements[s]||null,querySelectorAll:()=>[]}};
|
|
const lifecycleTarget=element();
|
|
const context={{module:{{exports:{{}}}},exports:{{}},console,URL,document}}; vm.createContext(context);
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MY_WORK))},'utf8'),context);
|
|
context.buildMyWork=context.module.exports; context.module={{exports:{{}}}};
|
|
vm.runInContext(fs.readFileSync({json.dumps(str(MODULE))},'utf8'),context);
|
|
const flow=context.module.exports({{document,lifecycleTarget,fetchSnapshot:async()=>({{
|
|
user:{{login:'timmy'}},issues:[{{number:7,title:'<img src=x onerror=alert(1)>',repository:'stackchain/dashboard',assignees:['timmy'],url:'https://forge.example/issues/7'}}],pull_requests:[]
|
|
}})}});
|
|
(async()=>{{
|
|
await flow.start();
|
|
const trigger=element({{dataset:{{progressiveWorkIndex:'0'}},closest:()=>trigger}});
|
|
list.listeners.click({{target:trigger,preventDefault(){{}}}});
|
|
const opened={{sheetHidden:sheet.hidden,title:title.textContent,meta:meta.textContent,reason:reason.textContent,giteaHidden:gitea.hidden,giteaHref:gitea.href,html:list.innerHTML}};
|
|
lifecycleTarget.listeners.keydown({{key:'Escape',preventDefault(){{}}}});
|
|
list.listeners.click({{target:trigger,preventDefault(){{}}}});
|
|
const first=flow.handoff(); const second=flow.handoff();
|
|
console.log(JSON.stringify({{opened,closed:sheet.hidden,focusCount:trigger.focusCount,first,second}}));
|
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
|
"""
|
|
result = subprocess.run(["node", "-e", harness], check=True, capture_output=True, text=True)
|
|
state = json.loads(result.stdout)
|
|
|
|
assert state["opened"] == {
|
|
"sheetHidden": False,
|
|
"title": "<img src=x onerror=alert(1)>",
|
|
"meta": "stackchain/dashboard#7 · Issue",
|
|
"reason": "Assigned to you",
|
|
"giteaHidden": False,
|
|
"giteaHref": "https://forge.example/issues/7",
|
|
"html": '<article class="my-work-card progressive-my-work-card"><button class="my-work-card-main" type="button" data-progressive-work-index="0"><strong><img src=x onerror=alert(1)></strong><span class="small">stackchain/dashboard#7 · Assigned to you</span></button></article>',
|
|
}
|
|
assert state["closed"] is False
|
|
assert state["focusCount"] == 1
|
|
assert state["first"]["openWork"] == {
|
|
"kind": "issue",
|
|
"key": "stackchain/dashboard#7",
|
|
"number": 7,
|
|
"repository": "stackchain/dashboard",
|
|
}
|
|
assert "openWork" not in state["second"]
|
|
|
|
|
|
def test_progressive_detail_is_mobile_safe_and_adopted_by_hydrated_workspace():
|
|
html = INDEX.read_text()
|
|
css = CSS.read_text()
|
|
dashboard = DASHBOARD.read_text()
|
|
|
|
assert 'id="progressive-work-detail" role="dialog" aria-modal="true"' in html
|
|
assert 'aria-labelledby="progressive-work-detail-title" hidden' in html
|
|
assert 'id="close-progressive-work-detail"' in html
|
|
assert 'id="open-progressive-work-gitea"' in html
|
|
assert ".progressive-work-detail[hidden] { display:none; }" in css
|
|
assert "max-height:100dvh" in css
|
|
assert "overflow-x:hidden" in css
|
|
assert "env(safe-area-inset-top)" in css
|
|
assert "env(safe-area-inset-bottom)" in css
|
|
assert ".progressive-work-detail-panel button, .progressive-work-detail-panel .button-link" in css
|
|
assert "min-height:44px" in css
|
|
assert "@media(max-width:320px)" in css
|
|
assert "progressiveWorkHandoff?.openWork" in dashboard
|
|
assert "openRoutedWork(progressiveItem, null)" in dashboard
|
|
assert "qs('#progressive-work-detail').hidden = true" in dashboard
|