feat: complete delegated mobile filing handoff (Closes #866)
This commit is contained in:
1 parent
5b954a7a53
commit
6633ba5244
18 files changed
+300
-24
No files matched your search
@@ -78,6 +78,24 @@ const fetchJson=async(url,options={{}})=>{{calls.push({{url,body:options.body||'
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_issue_receipt_uses_the_confirmed_canonical_gitea_url():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync=require({json.dumps(str(SYNC))});
|
||||
let item={{id:'unowned-link',operationId:'unowned-link',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Backlog capture',body:'',labelIds:[],unassigned:true}};
|
||||
const store={{claimNext:async()=>item,complete:async()=>{{item=null;}},release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0}};
|
||||
const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:19,title:'Backlog capture',assignees:[],url:'https://forge.example/git/o/r/issues/19'}};
|
||||
(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify(result.receipts[0]));}})();
|
||||
"""
|
||||
receipt = run_node(script)
|
||||
|
||||
assert receipt == {
|
||||
"id": "unowned-link",
|
||||
"status": "confirmed",
|
||||
"kind": "issue",
|
||||
"url": "https://forge.example/git/o/r/issues/19",
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_desired_blocker_state():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
|
||||
@@ -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-v101" in worker
|
||||
assert "stackchain-dashboard-shell-v102" in worker
|
||||
@@ -0,0 +1,89 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RECEIPT = Path(__file__).parents[1] / "frontend" / "issue-filing-receipt.js"
|
||||
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_confirmed_delegated_issue_opens_an_accessible_shareable_receipt():
|
||||
script = f"""
|
||||
const createReceipt = require({json.dumps(str(RECEIPT))});
|
||||
const element = () => ({{hidden:true,textContent:'',href:'',focused:false,focus(){{this.focused=true;}},addEventListener(name, fn){{this[name]=fn;}}}});
|
||||
const elements = {{root:element(),heading:element(),key:element(),title:element(),ownership:element(),openLink:element(),shareButton:element(),fileAnotherButton:element(),doneButton:element(),status:element()}};
|
||||
const controller = createReceipt({{...elements,navigator:{{share:async payload=>{{elements.shared=payload;}}}}}});
|
||||
controller.show({{repository:'stackchain/dashboard',number:866,title:'Finish the filing journey',assignees:['alex'],url:'https://forge.example/git/stackchain/dashboard/issues/866'}}, elements.doneButton);
|
||||
(async()=>{{await elements.shareButton.click();process.stdout.write(JSON.stringify({{
|
||||
hidden:elements.root.hidden,key:elements.key.textContent,title:elements.title.textContent,
|
||||
ownership:elements.ownership.textContent,href:elements.openLink.href,focused:elements.heading.focused,
|
||||
shared:elements.shared,
|
||||
}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"hidden": False,
|
||||
"key": "stackchain/dashboard#866",
|
||||
"title": "Finish the filing journey",
|
||||
"ownership": "Assigned to @alex.",
|
||||
"href": "https://forge.example/git/stackchain/dashboard/issues/866",
|
||||
"focused": True,
|
||||
"shared": {
|
||||
"title": "stackchain/dashboard#866 · Finish the filing journey",
|
||||
"text": "Finish the filing journey",
|
||||
"url": "https://forge.example/git/stackchain/dashboard/issues/866",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_unassigned_receipt_copies_link_and_escape_restores_launch_focus():
|
||||
script = f"""
|
||||
const createReceipt = require({json.dumps(str(RECEIPT))});
|
||||
const element = () => ({{hidden:true,textContent:'',href:'',focusCount:0,focus(){{this.focusCount++;}},addEventListener(name, fn){{this[name]=fn;}}}});
|
||||
const elements = {{root:element(),heading:element(),key:element(),title:element(),ownership:element(),openLink:element(),shareButton:element(),fileAnotherButton:element(),doneButton:element(),status:element()}};
|
||||
const copied=[];const launcher=element();
|
||||
const controller=createReceipt({{...elements,navigator:{{}},clipboard:{{writeText:async value=>copied.push(value)}}}});
|
||||
controller.show({{repository:'stackchain/dashboard',number:867,title:'Unowned follow-up',assignees:[],url:'https://forge.example/git/stackchain/dashboard/issues/867'}},launcher);
|
||||
(async()=>{{await elements.shareButton.click();elements.root.keydown({{key:'Escape',preventDefault(){{elements.prevented=true;}}}});process.stdout.write(JSON.stringify({{
|
||||
ownership:elements.ownership.textContent,copied,status:elements.status.textContent,hidden:elements.root.hidden,
|
||||
restored:launcher.focusCount,prevented:elements.prevented,
|
||||
}}));}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"ownership": "Created with no owner.",
|
||||
"copied": ["https://forge.example/git/stackchain/dashboard/issues/867"],
|
||||
"status": "Issue link copied.",
|
||||
"hidden": True,
|
||||
"restored": 1,
|
||||
"prevented": True,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_shell_wires_confirmed_non_my_work_issues_to_the_receipt():
|
||||
index = INDEX.read_text()
|
||||
css = CSS.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
|
||||
assert 'id="issue-filing-receipt" role="dialog" aria-modal="true"' in index
|
||||
assert 'id="issue-filing-receipt-heading"' in index
|
||||
assert 'id="issue-filing-receipt-open"' in index
|
||||
assert 'id="issue-filing-receipt-share"' in index
|
||||
assert 'id="issue-filing-receipt-another"' in index
|
||||
assert 'id="issue-filing-receipt-done"' in index
|
||||
assert '<script src="static/issue-filing-receipt.js"></script>' in index
|
||||
assert '.issue-filing-receipt-panel' in css
|
||||
assert 'padding-bottom:calc(18px + env(safe-area-inset-bottom))' in css
|
||||
assert '.issue-filing-receipt-actions' in css
|
||||
assert 'min-height:44px' in css
|
||||
assert 'createIssueFilingReceipt({' in dashboard
|
||||
assert "issueFilingReceipt.show(confirmed, qs('#new-issue'))" in dashboard
|
||||
@@ -347,5 +347,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-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -137,4 +137,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-v101" in worker
|
||||
assert "stackchain-dashboard-shell-v102" 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-v101" in worker
|
||||
assert "stackchain-dashboard-shell-v102" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v101" in worker
|
||||
assert "stackchain-dashboard-shell-v102" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" 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
|
||||
@@ -120,11 +120,11 @@ async function dispatchMessage(data, ports = []) {{
|
||||
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null) {{
|
||||
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null, url = null) {{
|
||||
let pending;
|
||||
listeners.notificationclick({{
|
||||
action,
|
||||
notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId}}, close: () => {{ state.notificationClosed = true; }}}},
|
||||
notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId, ...(url ? {{url}} : {{}})}}, close: () => {{ state.notificationClosed = true; }}}},
|
||||
waitUntil: promise => {{ pending = promise; }},
|
||||
}});
|
||||
if (pending) await pending;
|
||||
@@ -150,7 +150,7 @@ async function dispatchPush(payload) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -159,14 +159,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" 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-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -174,7 +174,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-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -182,14 +182,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" 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-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -198,21 +198,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-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" 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-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" 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-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -460,6 +460,36 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou
|
||||
assert result["notificationClosed"] is True
|
||||
|
||||
|
||||
def test_created_issue_receipt_tap_opens_the_confirmed_canonical_gitea_url():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const issueUrl = 'https://forge.example/git/stackchain/api/issues/44';
|
||||
state.receiptLogin = 'timmy';
|
||||
state.flushResult = {login:'timmy', receipts:[
|
||||
{id:'capture-1',status:'confirmed',kind:'issue',url:issueUrl},
|
||||
]};
|
||||
state.clientList = [{url:'https://forge.example/dashboard/', navigate:async function(url){ this.url=url; }, focus:async function(){ state.focused.push(this.url); }}];
|
||||
await dispatchSync('stackchain-issue-outbox-v1');
|
||||
await dispatchNotificationClick('', '', null, 'stackchain-delivery-capture-1', issueUrl);
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["notifications"] == [{
|
||||
"title": "Queued issue created",
|
||||
"options": {
|
||||
"body": "Tap to open it in Stackchain.",
|
||||
"tag": "stackchain-delivery-capture-1",
|
||||
"data": {"url": "https://forge.example/git/stackchain/api/issues/44"},
|
||||
},
|
||||
}]
|
||||
assert result["focused"] == [
|
||||
"https://forge.example/git/stackchain/api/issues/44"
|
||||
]
|
||||
assert result["opened"] == []
|
||||
assert result["notificationClosed"] is True
|
||||
|
||||
|
||||
def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -764,7 +794,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-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -873,6 +903,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/mobile-composer-viewport.js",
|
||||
"/dashboard/static/mention-composer.js",
|
||||
"/dashboard/static/push-notifications.js",
|
||||
"/dashboard/static/issue-filing-receipt.js",
|
||||
"/dashboard/static/background-issue-sync.js",
|
||||
}
|
||||
|
||||
|
||||
@@ -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-v101';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v102';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
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-v101" in source
|
||||
assert "stackchain-dashboard-shell-v102" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user