feat: deliver offline issue captures after app close (#250)
Closes #250
This commit is contained in:
1 parent
5f3f0176f1
commit
a4beca2d9e
9 files changed
+757
-29
No files matched your search
@@ -0,0 +1,315 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
SYNC = Path(__file__).parents[1] / "frontend" / "background-issue-sync.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
completed = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, check=True, text=True
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_closed_app_sync_delivers_matching_issue_once_with_original_idempotency_key():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const item = {{
|
||||
id:'capture-1', operationId:'capture-1', ownerLogin:'timmy', status:'queued',
|
||||
repository:'stackchain/api', title:'Offline report', body:'Full context',
|
||||
labelIds:[3], milestoneId:4, dueDate:'2026-08-09', queuedAt:100,
|
||||
}};
|
||||
const state = {{item, completed:[], released:[], failed:[], calls:[]}};
|
||||
const store = {{
|
||||
claimNext: async owner => state.item && state.item.ownerLogin === owner ? {{...state.item}} : null,
|
||||
complete: async id => {{ state.completed.push(id); state.item = null; }},
|
||||
release: async id => state.released.push(id),
|
||||
fail: async (id, message) => state.failed.push({{id,message}}),
|
||||
}};
|
||||
const fetchJson = async (url, options = {{}}) => {{
|
||||
state.calls.push({{url,options}});
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
return {{repository:'stackchain/api',number:251,title:'Offline report'}};
|
||||
}};
|
||||
(async () => {{
|
||||
const sync = createBackgroundIssueSync({{store,fetchJson}});
|
||||
const result = await sync.flush();
|
||||
process.stdout.write(JSON.stringify({{state,result}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["result"]["confirmed"][0]["number"] == 251
|
||||
assert output["state"]["completed"] == ["capture-1"]
|
||||
assert output["state"]["released"] == []
|
||||
assert output["state"]["failed"] == []
|
||||
assert output["state"]["calls"][0]["url"] == "api/v1/background-identity"
|
||||
mutation = output["state"]["calls"][1]
|
||||
assert mutation["url"] == "api/v1/repos/stackchain/api/issues"
|
||||
assert mutation["options"]["headers"]["Idempotency-Key"] == "capture-1"
|
||||
assert json.loads(mutation["options"]["body"]) == {
|
||||
"title": "Offline report",
|
||||
"body": "Full context",
|
||||
"label_ids": [3],
|
||||
"milestone_id": 4,
|
||||
"due_date": "2026-08-09T23:59:59Z",
|
||||
}
|
||||
|
||||
|
||||
def test_closed_app_sync_leaves_another_accounts_issue_queued():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const state = {{owners:[],mutations:0}};
|
||||
const store = {{
|
||||
claimNext: async owner => {{ state.owners.push(owner); return null; }},
|
||||
countBlocked: async owner => owner === 'alexander' ? 1 : 0,
|
||||
complete: async () => {{}},
|
||||
}};
|
||||
const fetchJson = async url => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'alexander'}};
|
||||
state.mutations += 1;
|
||||
}};
|
||||
(async () => {{
|
||||
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify({{state,result}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"] == {"owners": ["alexander"], "mutations": 0}
|
||||
assert output["result"]["confirmed"] == []
|
||||
assert output["result"]["blocked"] == 1
|
||||
|
||||
|
||||
def test_transient_delivery_failure_releases_claim_and_requests_another_sync():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let item = {{id:'capture-2',operationId:'capture-2',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}};
|
||||
const state = {{released:[],completed:[]}};
|
||||
const store = {{
|
||||
claimNext: async () => item ? (item = null, {{id:'capture-2',operationId:'capture-2',ownerLogin:'timmy',repository:'o/r',title:'Retry',body:'Later',labelIds:[]}}) : null,
|
||||
release: async id => state.released.push(id),
|
||||
complete: async id => state.completed.push(id),
|
||||
}};
|
||||
const fetchJson = async url => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
const error = new Error('Gitea unavailable'); error.status = 503; throw error;
|
||||
}};
|
||||
(async () => {{
|
||||
let error = null;
|
||||
try {{ await createBackgroundIssueSync({{store,fetchJson}}).flush(); }}
|
||||
catch (caught) {{ error = caught.message; }}
|
||||
process.stdout.write(JSON.stringify({{state,error}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["released"] == ["capture-2"]
|
||||
assert output["state"]["completed"] == []
|
||||
assert output["error"] == "Gitea unavailable"
|
||||
|
||||
|
||||
def test_permanent_delivery_failure_marks_issue_for_foreground_attention():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let claimed = false;
|
||||
const state = {{failed:[],released:[]}};
|
||||
const store = {{
|
||||
claimNext: async () => claimed ? null : (claimed = true, {{id:'capture-3',operationId:'capture-3',ownerLogin:'timmy',repository:'o/r',title:'Invalid',body:'',labelIds:[]}}),
|
||||
fail: async (id, message) => state.failed.push({{id,message}}),
|
||||
release: async id => state.released.push(id),
|
||||
countBlocked: async () => 0,
|
||||
}};
|
||||
const fetchJson = async url => {{
|
||||
if (url === 'api/v1/background-identity') return {{login:'timmy'}};
|
||||
const error = new Error('Title is invalid'); error.status = 422; throw error;
|
||||
}};
|
||||
(async () => {{
|
||||
const result = await createBackgroundIssueSync({{store,fetchJson}}).flush();
|
||||
process.stdout.write(JSON.stringify({{state,result}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"]["failed"] == [
|
||||
{"id": "capture-3", "message": "Title is invalid"}
|
||||
]
|
||||
assert output["state"]["released"] == []
|
||||
assert output["result"]["attention"] == 1
|
||||
|
||||
|
||||
def test_issue_store_atomically_grants_one_delivery_claim():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records = new Map();
|
||||
let tail = Promise.resolve();
|
||||
const transaction = work => {{
|
||||
const run = tail.then(() => work({{
|
||||
getAll: async () => [...records.values()].map(value => ({{...value}})),
|
||||
put: async value => records.set(value.id, {{...value}}),
|
||||
delete: async id => records.delete(id),
|
||||
}}));
|
||||
tail = run.catch(() => {{}});
|
||||
return run;
|
||||
}};
|
||||
(async () => {{
|
||||
const store = createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>1000,claimMs:5000}});
|
||||
await store.reconcile([{{id:'same',operationId:'same',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Once',labelIds:[]}}]);
|
||||
const claims = await Promise.all([store.claimNext('timmy'),store.claimNext('timmy')]);
|
||||
process.stdout.write(JSON.stringify({{claims,records:[...records.values()]}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert sum(claim is not None for claim in output["claims"]) == 1
|
||||
assert output["records"][0]["status"] == "sending"
|
||||
assert output["records"][0]["claimUntil"] == 6000
|
||||
|
||||
|
||||
def test_foreground_and_worker_race_still_posts_one_issue():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records = new Map(); let tail = Promise.resolve();
|
||||
const transaction = work => {{
|
||||
const run = tail.then(() => work({{
|
||||
getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}), delete:async id=>records.delete(id),
|
||||
}}));
|
||||
tail=run.catch(()=>{{}}); return run;
|
||||
}};
|
||||
const item={{id:'race',operationId:'race',ownerLogin:'timmy',status:'queued',repository:'o/r',title:'Exactly once',body:'',labelIds:[]}};
|
||||
let mutations=0;
|
||||
const fetchJson=async url=>{{
|
||||
if(url==='api/v1/background-identity') return {{login:'timmy'}};
|
||||
mutations+=1; return {{number:77}};
|
||||
}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction}});
|
||||
await store.reconcile([item]);
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson}});
|
||||
const [foreground,worker]=await Promise.all([sync.send(item,'timmy'),sync.flush()]);
|
||||
process.stdout.write(JSON.stringify({{foreground,worker,mutations,remaining:[...records.values()]}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["mutations"] == 1
|
||||
assert len(output["remaining"]) == 1
|
||||
assert output["remaining"][0]["status"] == "sent"
|
||||
delivered = int(bool(output["foreground"].get("issue"))) + len(
|
||||
output["worker"]["confirmed"]
|
||||
)
|
||||
assert delivered == 1
|
||||
|
||||
|
||||
def test_foreground_send_upserts_before_claim_when_mirror_is_still_pending():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
let record = null; const state={{upserts:0,mutations:0}};
|
||||
const store={{
|
||||
upsert:async item=>{{state.upserts+=1;record={{...item}};}},
|
||||
claim:async(id,owner)=>record?.id===id&&record?.ownerLogin===owner?{{...record}}:null,
|
||||
complete:async()=>{{record=null;}}, release:async()=>{{}}, fail:async()=>{{}},
|
||||
}};
|
||||
const fetchJson=async()=>{{state.mutations+=1;return {{number:88}};}};
|
||||
(async()=>{{
|
||||
const sync=createBackgroundIssueSync({{store,fetchJson}});
|
||||
const result=await sync.send({{id:'early',operationId:'early',ownerLogin:'timmy',repository:'o/r',title:'Fast',body:'',labelIds:[]}},'timmy');
|
||||
process.stdout.write(JSON.stringify({{state,result,record}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"] == {"upserts": 1, "mutations": 1}
|
||||
assert output["result"]["issue"]["number"] == 88
|
||||
assert output["record"] is None
|
||||
|
||||
|
||||
def test_completed_delivery_leaves_non_replayable_tombstone_for_next_page():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map();let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100}});
|
||||
await store.reconcile([{{id:'done',ownerLogin:'timmy',status:'queued'}}]);
|
||||
await store.claimNext('timmy');
|
||||
await store.complete('done');
|
||||
const replay=await store.claimNext('timmy');
|
||||
const snapshot=await store.snapshot();
|
||||
process.stdout.write(JSON.stringify({{replay,snapshot}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["replay"] is None
|
||||
assert output["snapshot"] == [
|
||||
{"id": "done", "ownerLogin": "timmy", "status": "sent", "claimUntil": 0}
|
||||
]
|
||||
|
||||
|
||||
def test_user_edit_resets_worker_attention_item_for_retry():
|
||||
script = f"""
|
||||
const createBackgroundIssueSync = require({json.dumps(str(SYNC))});
|
||||
const records=new Map([['edit',{{id:'edit',ownerLogin:'timmy',status:'attention',title:'Bad',error:'Invalid'}}]]);
|
||||
let tail=Promise.resolve();
|
||||
const transaction=work=>{{const run=tail.then(()=>work({{
|
||||
getAll:async()=>[...records.values()].map(value=>({{...value}})),
|
||||
put:async value=>records.set(value.id,{{...value}}),delete:async id=>records.delete(id),
|
||||
}}));tail=run.catch(()=>{{}});return run;}};
|
||||
(async()=>{{
|
||||
const store=createBackgroundIssueSync.createIssueSyncStore({{transaction,now:()=>100}});
|
||||
await store.reconcile([{{id:'edit',ownerLogin:'timmy',status:'queued',title:'Fixed'}}]);
|
||||
const claimed=await store.claimNext('timmy');
|
||||
process.stdout.write(JSON.stringify(claimed));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["title"] == "Fixed"
|
||||
assert output["status"] == "sending"
|
||||
assert "error" not in output
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_background_identity_is_lightweight_and_never_cacheable(monkeypatch):
|
||||
calls = 0
|
||||
|
||||
async def user():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"id": 7, "login": "timmy", "email": "private@example.com"}
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/background-identity")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"login": "timmy"}
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_indexeddb_outbox_and_background_sync_fallback():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/background-issue-sync.js"></script>' in html
|
||||
assert "const backgroundIssueStore = createIssueSyncStore();" in html
|
||||
assert "createBackgroundIssueSync({" in html
|
||||
assert "backgroundSync: backgroundIssueSync" in html
|
||||
assert "registration.sync.register('stackchain-issue-outbox-v1')" in html
|
||||
assert "backgroundIssueSync.snapshot().then(records => issueOutbox.reconcileBackground(records))" in html
|
||||
assert "if ('indexedDB' in window)" in html
|
||||
@@ -163,6 +163,100 @@ Promise.all([reconnect,sendNow]).then(results => process.stdout.write(JSON.strin
|
||||
assert sum(len(result["confirmed"]) for result in output["results"]) >= 1
|
||||
|
||||
|
||||
def test_issue_outbox_mirrors_queue_and_registers_background_sync():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const state = {{snapshots:[],syncs:0}};
|
||||
const backgroundSync = {{
|
||||
reconcile: async items => state.snapshots.push(items.map(item => ({{...item}}))),
|
||||
requestSync: async () => {{ state.syncs += 1; }},
|
||||
}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, backgroundSync, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'background-1',
|
||||
}});
|
||||
outbox.enqueue({{repository:'stackchain/api',title:'Close the app',body:'Still deliver'}});
|
||||
setTimeout(() => process.stdout.write(JSON.stringify(state)), 0);
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["snapshots"][0][0]["operationId"] == "background-1"
|
||||
assert output["snapshots"][0][0]["ownerLogin"] == "timmy"
|
||||
assert output["syncs"] == 1
|
||||
|
||||
|
||||
def test_foreground_delivery_uses_same_atomic_background_claim():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const state = {{backgroundCalls:0,directCalls:0}};
|
||||
const backgroundSync = {{
|
||||
reconcile: async () => {{}}, requestSync: async () => {{}},
|
||||
send: async (item, login) => {{ state.backgroundCalls += 1; return {{issue:{{number:9}}, claimed:item.id + ':' + login}}; }},
|
||||
}};
|
||||
const outbox = createIssueOutbox({{
|
||||
storage, backgroundSync, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'shared-claim',
|
||||
fetchJson:async () => {{ state.directCalls += 1; return {{number:10}}; }},
|
||||
}});
|
||||
outbox.enqueue({{repository:'o/r',title:'One owner'}});
|
||||
outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{state,result,remaining:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["state"] == {"backgroundCalls": 1, "directCalls": 0}
|
||||
assert output["result"]["confirmed"][0]["number"] == 9
|
||||
assert output["remaining"] == []
|
||||
|
||||
|
||||
def test_foreground_background_delivery_surfaces_permanent_failure_immediately():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const error=new Error('Title is invalid');error.status=422;
|
||||
const backgroundSync={{reconcile:async()=>{{}},requestSync:async()=>{{}},send:async()=>({{attention:true,error}})}};
|
||||
const outbox=createIssueOutbox({{storage,backgroundSync,getOwnerLogin:()=>'timmy',createOperationId:()=>'invalid'}});
|
||||
outbox.enqueue({{repository:'o/r',title:'Bad'}});
|
||||
outbox.flush('timmy').then(result=>process.stdout.write(JSON.stringify({{result,items:outbox.list()}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["items"][0]["status"] == "attention"
|
||||
assert output["items"][0]["error"] == "Title is invalid"
|
||||
assert output["result"]["confirmed"] == []
|
||||
|
||||
|
||||
def test_page_reconciles_worker_success_and_attention_into_visible_outbox():
|
||||
script = f"""
|
||||
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
values.set('stackchain.issue-outbox.v1',JSON.stringify({{version:2,items:[
|
||||
{{id:'sent',operationId:'sent',ownerLogin:'timmy',status:'queued',title:'Done'}},
|
||||
{{id:'bad',operationId:'bad',ownerLogin:'timmy',status:'queued',title:'Fix me'}},
|
||||
]}}));
|
||||
const outbox=createIssueOutbox({{storage}});
|
||||
outbox.reconcileBackground([
|
||||
{{id:'sent',status:'sent'}},
|
||||
{{id:'bad',status:'attention',error:'Title is invalid'}},
|
||||
]);
|
||||
process.stdout.write(JSON.stringify(outbox.list()));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == [
|
||||
{
|
||||
"id": "bad",
|
||||
"operationId": "bad",
|
||||
"ownerLogin": "timmy",
|
||||
"status": "attention",
|
||||
"title": "Fix me",
|
||||
"error": "Title is invalid",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actions():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], failFetch: false, fetchStatus: 200, cachedBody: null }};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, failFetch: false, fetchStatus: 200, cachedBody: null }};
|
||||
const cache = {{
|
||||
addAll: async urls => {{ state.added = urls; }},
|
||||
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
|
||||
@@ -27,7 +27,9 @@ const context = {{
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
clients: {{ claim: async () => {{ state.claimed = true; }} }},
|
||||
__issueSync: {{ flush: async () => {{ state.backgroundFlushes += 1; }} }},
|
||||
}},
|
||||
importScripts: () => {{}},
|
||||
caches: {{
|
||||
open: async () => cache,
|
||||
keys: async () => ['stackchain-dashboard-old', 'another-app-cache'],
|
||||
@@ -53,6 +55,11 @@ async function dispatch(name, request) {{
|
||||
if (pending) await pending;
|
||||
return response ? await response : null;
|
||||
}}
|
||||
async function dispatchSync(tag) {{
|
||||
let pending;
|
||||
listeners.sync({{ tag, waitUntil: promise => {{ pending = promise; }} }});
|
||||
if (pending) await pending;
|
||||
}}
|
||||
(async () => {{
|
||||
{scenario}
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
@@ -63,10 +70,22 @@ async function dispatch(name, request) {{
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_mobile_attention_queue_ships_in_a_new_shell_cache():
|
||||
def test_background_issue_sync_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v11" in source
|
||||
assert "stackchain-dashboard-shell-v12" in source
|
||||
|
||||
|
||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchSync('stackchain-issue-outbox-v1');
|
||||
await dispatchSync('another-app-sync');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["backgroundFlushes"] == 1
|
||||
|
||||
|
||||
def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
@@ -103,6 +122,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/work-route.js",
|
||||
"/dashboard/static/context-poller.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/background-issue-sync.js",
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user