diff --git a/frontend/authored-outbox.js b/frontend/authored-outbox.js index 05f7252..69cdd7a 100644 --- a/frontend/authored-outbox.js +++ b/frontend/authored-outbox.js @@ -4,7 +4,7 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) ); const pending = new Map(); - const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close']); + const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker']); function reviewFingerprint(message) { return JSON.stringify({ @@ -91,6 +91,11 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, draftFingerprint: String(message.draftFingerprint || ''), progressFingerprint: String(message.progressFingerprint || ''), } : {}), + ...(message.kind === 'issue-blocker' ? { + blockerRepository: String(message.blockerRepository || ''), + blockerNumber: Number(message.blockerNumber || 0), + present: message.present === true, + } : {}), }; items.push(item); write(items, mirror); @@ -163,6 +168,9 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, if (item.kind === 'issue-close') { return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close'; } + if (item.kind === 'issue-blocker') { + return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers'; + } if (item.kind === 'pull-review') { return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review'; } @@ -212,6 +220,26 @@ function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId }, }); if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.'); + } else if (item.kind === 'issue-blocker') { + result = await fetchJson(endpoint(item), { + method: 'PATCH', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Idempotency-Key': item.operationId, + }, + body: JSON.stringify({ + repository: item.blockerRepository, + number: item.blockerNumber, + present: item.present, + }), + }); + const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : []; + const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository && + Number(candidate?.number) === item.blockerNumber); + if (result?.number !== item.number || result?.dependencies_available !== true || present !== item.present) { + throw new Error('Blocker change was not confirmed.'); + } } else { const body = item.kind === 'pull-review' ? { body: item.body, diff --git a/frontend/background-issue-sync.js b/frontend/background-issue-sync.js index 36e9c0a..e63d801 100644 --- a/frontend/background-issue-sync.js +++ b/frontend/background-issue-sync.js @@ -368,6 +368,24 @@ function createBackgroundIssueSync({ }, }; } + if (item.kind === 'issue-blocker') { + return { + url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers', + options: { + method: 'PATCH', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Idempotency-Key': item.operationId, + }, + body: JSON.stringify({ + repository: item.blockerRepository, + number: item.blockerNumber, + present: item.present === true, + }), + }, + }; + } if (item.kind === 'pull-review') { return { url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review', @@ -612,6 +630,16 @@ function createBackgroundIssueSync({ error.status = 422; throw error; } + if (item.kind === 'issue-blocker') { + const dependencies = Array.isArray(delivered?.dependencies) ? delivered.dependencies : []; + const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository && + Number(candidate?.number) === item.blockerNumber); + if (delivered?.number !== item.number || delivered?.dependencies_available !== true || present !== item.present) { + const error = new Error('Blocker change was not confirmed.'); + error.status = 422; + throw error; + } + } await completeClaim(item, delivered); const receipt = receiptFor(item, 'confirmed', delivered); return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt }; diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 6ac9ffd..6f68a83 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -395,6 +395,9 @@ backgroundSync: backgroundIssueSync, getOwnerLogin: () => confirmedOwnerLogin, }); + const queueOfflineIssueBlocker = createOfflineIssueBlocker({ + enqueueDurably: message => authoredOutbox.enqueueDurably(message), + }); const notificationReadOutbox = createNotificationReadOutbox({ storage: localStorage, fetchJson: fetchReviewJson, coordinator: outboxCoordinator, backgroundSync: backgroundIssueSync, @@ -1342,7 +1345,7 @@ const addButton = qs('#add-plan-preview'); panel.hidden = false; list.innerHTML = ''; - qs('#manage-issue-blockers').hidden = !assignedIssue || selectedIssueOffline; + qs('#manage-issue-blockers').hidden = !assignedIssue; qs('#start-unblocked-issue').hidden = true; if (!assignedIssue) qs('#issue-blocker-manager').hidden = true; if (assignedIssue) { @@ -1403,11 +1406,20 @@ } async function mutateIssueBlocker(blocker, remove, button) { - if (!selectedIssue || selectedIssueOffline) return; + if (!selectedIssue) return; const item = selectedIssue; button.disabled = true; qs('#issue-blocker-status').textContent = remove ? 'Removing blocker…' : 'Adding blocker…'; try { + if (selectedIssueOffline) { + await queueOfflineIssueBlocker(item, blocker, !remove); + if (selectedIssue !== item) return; + button.textContent = 'Queued'; + qs('#issue-blocker-status').textContent = remove ? + 'Blocker removal queued. Today remains blocked until Stackchain confirms delivery.' : + 'Blocker addition queued. Today readiness will update after Stackchain confirms delivery.'; + return { queued:true }; + } const result = remove ? await issueController.updateBlocker(selectedIssue, blocker, true) : await issueController.updateBlocker(selectedIssue, blocker, false); @@ -1437,8 +1449,8 @@ results.querySelectorAll('[data-blocker-result]').forEach(button => { button.addEventListener('click', () => { const blocker = issueBlockerCandidates[Number(button.dataset.blockerResult)]; - if (blocker) mutateIssueBlocker(blocker, false, button).then(() => { - if (selectedIssueDetail?.dependencies?.some(candidate => + if (blocker) mutateIssueBlocker(blocker, false, button).then(outcome => { + if (outcome?.queued || selectedIssueDetail?.dependencies?.some(candidate => candidate.repository === blocker.repository && candidate.number === blocker.number )) { qs('#issue-blocker-manager').hidden = true; @@ -1474,10 +1486,14 @@ qs('#issue-blocker-search-status').textContent = 'Searching open issues…'; issueBlockerSearchTimer = setTimeout(async () => { try { - const payload = await api('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10'); + const candidates = selectedIssueOffline ? workSession.items() : + (await api('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10')).items; const existing = selectedIssueDetail?.dependencies || []; - const items = (payload.items || []).filter(result => + const normalizedQuery = query.toLowerCase(); + const items = (candidates || []).filter(result => result.kind === 'issue' && result.state === 'open' && + (!selectedIssueOffline || (String(result.repository || '') + '#' + result.number + ' ' + String(result.title || '')) + .toLowerCase().includes(normalizedQuery)) && !(result.repository === selectedIssue?.repository && result.number === selectedIssue?.number) && !existing.some(blocker => blocker.repository === result.repository && blocker.number === result.number) ); diff --git a/frontend/index.html b/frontend/index.html index 8f661ac..7ea3752 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -729,6 +729,7 @@ + diff --git a/frontend/offline-issue-blocker.js b/frontend/offline-issue-blocker.js new file mode 100644 index 0000000..5fc1a55 --- /dev/null +++ b/frontend/offline-issue-blocker.js @@ -0,0 +1,21 @@ +function createOfflineIssueBlocker({ + enqueueDurably, + onQueued = () => {}, + createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2), +}) { + return async function queueOfflineIssueBlocker(item, blocker, present) { + const admission = await enqueueDurably({ + kind: 'issue-blocker', + repository: String(item.repository || ''), + number: Number(item.number || 0), + blockerRepository: String(blocker.repository || ''), + blockerNumber: Number(blocker.number || 0), + present: present === true, + operationId: String(createOperationId()).slice(0, 128), + }); + onQueued(item, blocker, present === true, admission); + return { admission }; + }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueBlocker; diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 8ce435a..e0ef9e7 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -22,6 +22,7 @@ const SHELL = [ BASE + 'static/issue-outbox.js', BASE + 'static/authored-outbox.js', BASE + 'static/offline-issue-close.js', + BASE + 'static/offline-issue-blocker.js', BASE + 'static/notification-read-outbox.js', BASE + 'static/offline-work.js', BASE + 'static/offline-today.js', diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py index 54649f5..e161532 100644 --- a/src/gitea_proxy.py +++ b/src/gitea_proxy.py @@ -1634,6 +1634,19 @@ async def mutate_assigned_issue_dependency( ): raise IssueNotAvailableError("assigned issue not found") + current = await issue_dependencies(repository, number) + currently_present = any( + item["repository"] == blocker_repository and item["number"] == blocker_number + for item in current + ) + if currently_present != remove: + return { + "repository": repository, + "number": number, + "dependencies_available": True, + "dependencies": current, + } + if not remove: candidate = await fetch(f"repos/{blocker_repository}/issues/{blocker_number}") if ( @@ -1643,13 +1656,6 @@ async def mutate_assigned_issue_dependency( or isinstance(candidate.get("pull_request"), dict) ): raise IssueDependencyInvalidError("blocker must be an accessible open issue") - current = await issue_dependencies(repository, number) - if any( - item["repository"] == blocker_repository and item["number"] == blocker_number - for item in current - ): - raise IssueDependencyInvalidError("that issue is already a blocker") - owner, repo = blocker_repository.split("/", 1) response = await _get_client().request( "DELETE" if remove else "POST", diff --git a/src/main.py b/src/main.py index a3d724a..35b33cf 100644 --- a/src/main.py +++ b/src/main.py @@ -511,6 +511,10 @@ class IssueBlockerUpdate(BaseModel): number: PositiveInt +class IssueBlockerDesiredState(IssueBlockerUpdate): + present: bool + + class PullReviewComment(BaseModel): path: str = Field(min_length=1, max_length=1_000) body: str = Field(min_length=1, max_length=10_000) @@ -3269,6 +3273,18 @@ async def remove_assigned_issue_blocker( return await _mutate_assigned_issue_blocker(update, owner, repo, number, True) +@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/blockers") +async def set_assigned_issue_blocker_state( + update: IssueBlockerDesiredState, + owner: str, + repo: str, + number: int = PathParam(gt=0), +) -> JSONResponse: + return await _mutate_assigned_issue_blocker( + update, owner, repo, number, not update.present + ) + + @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/content") async def update_assigned_issue_content( update: IssueContentUpdate, diff --git a/tests/test_authored_outbox.py b/tests/test_authored_outbox.py index 85d0b9a..5c95054 100644 --- a/tests/test_authored_outbox.py +++ b/tests/test_authored_outbox.py @@ -77,6 +77,41 @@ outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{persi assert output["remaining"] == [] +def test_authored_outbox_persists_and_delivers_desired_blocker_state(): + script = f""" +const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); +const values = new Map(); const calls = []; +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +const outbox = createAuthoredOutbox({{ + storage, getOwnerLogin:()=> 'timmy', + fetchJson: async (url, options) => {{ + calls.push({{url,method:options.method,key:options.headers['Idempotency-Key'],body:JSON.parse(options.body)}}); + return {{repository:'stackchain/dashboard',number:17,dependencies_available:true,dependencies:[]}}; + }}, +}}); +const queued = outbox.enqueue({{ + kind:'issue-blocker',repository:'stackchain/dashboard',number:17,operationId:'blocker-op', + blockerRepository:'stackchain/api',blockerNumber:9,present:false, +}}); +const restored = createAuthoredOutbox({{storage}}).list()[0]; +outbox.flush('timmy').then(result => process.stdout.write(JSON.stringify({{queued,restored,calls,result,remaining:outbox.list()}}))); +""" + output = run_node(script) + + assert output["queued"]["blockerRepository"] == "stackchain/api" + assert output["queued"]["blockerNumber"] == 9 + assert output["queued"]["present"] is False + assert output["restored"]["present"] is False + assert output["calls"] == [{ + "url": "api/v1/repos/stackchain/dashboard/issues/17/blockers", + "method": "PATCH", + "key": "blocker-op", + "body": {"repository": "stackchain/api", "number": 9, "present": False}, + }] + assert output["result"]["confirmed"][0]["dependencies"] == [] + assert output["remaining"] == [] + + def test_authored_outbox_exposes_sending_then_preserves_transient_attempt_details(): script = f""" const createAuthoredOutbox = require({json.dumps(str(OUTBOX))}); diff --git a/tests/test_background_issue_sync.py b/tests/test_background_issue_sync.py index 2296d26..cc32789 100644 --- a/tests/test_background_issue_sync.py +++ b/tests/test_background_issue_sync.py @@ -64,6 +64,54 @@ const fetchJson = async (url, options = {{}}) => {{ } +def test_closed_app_sync_delivers_desired_blocker_state(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +let item = {{ + id:'blocker-1',operationId:'blocker-1',kind:'issue-blocker',ownerLogin:'timmy',status:'queued', + repository:'stackchain/dashboard',number:17,blockerRepository:'stackchain/api',blockerNumber:9,present:false, +}}; +const state={{calls:[],completed:[]}}; +const store={{ + claimNext:async()=>item?{{...item}}:null, + complete:async id=>{{state.completed.push(id);item=null;}}, + release:async()=>{{}},fail:async()=>{{}},countBlocked:async()=>0, +}}; +const fetchJson=async(url,options={{}})=>{{ + state.calls.push({{url,method:options.method,key:options.headers?.['Idempotency-Key']||'',body:options.body?JSON.parse(options.body):null}}); + if(url==='api/v1/background-identity')return{{login:'timmy'}}; + return{{repository:'stackchain/dashboard',number:17,dependencies_available:true,dependencies:[]}}; +}}; +(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})(); +""" + output = run_node(script) + + assert output["state"]["calls"][1] == { + "url": "api/v1/repos/stackchain/dashboard/issues/17/blockers", + "method": "PATCH", + "key": "blocker-1", + "body": {"repository": "stackchain/api", "number": 9, "present": False}, + } + assert output["state"]["completed"] == ["blocker-1"] + assert output["result"]["confirmed"][0]["dependencies"] == [] + + +def test_closed_app_sync_keeps_unconfirmed_blocker_change_actionable(): + script = f""" +const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); +let item={{id:'blocker-2',operationId:'blocker-2',kind:'issue-blocker',ownerLogin:'timmy',status:'queued',repository:'o/r',number:1,blockerRepository:'o/b',blockerNumber:2,present:false}}; +const state={{completed:[],failed:[]}}; +const store={{claimNext:async()=>item?{{...item}}:null,complete:async id=>{{state.completed.push(id);item=null;}},release:async()=>{{}},fail:async(id,message)=>{{state.failed.push([id,message]);item=null;}},countBlocked:async()=>0}}; +const fetchJson=async url=>url==='api/v1/background-identity'?{{login:'timmy'}}:{{repository:'o/r',number:1,dependencies_available:true,dependencies:[{{repository:'o/b',number:2}}]}}; +(async()=>{{const result=await createBackgroundIssueSync({{store,fetchJson}}).flush();process.stdout.write(JSON.stringify({{state,result}}));}})(); +""" + output = run_node(script) + + assert output["state"]["completed"] == [] + assert output["state"]["failed"] == [["blocker-2", "Blocker change was not confirmed."]] + assert output["result"]["attention"] == 1 + + def test_capture_attachment_retry_resumes_after_creation_without_duplicate_issue(): script = f""" const createBackgroundIssueSync = require({json.dumps(str(SYNC))}); diff --git a/tests/test_issue_api.py b/tests/test_issue_api.py index 84a71a5..85c69d3 100644 --- a/tests/test_issue_api.py +++ b/tests/test_issue_api.py @@ -1977,6 +1977,32 @@ async def test_assigned_issue_blocker_route_returns_canonical_dependencies(monke ] +@pytest.mark.anyio +async def test_assigned_issue_blocker_patch_converges_to_desired_state(monkeypatch): + calls = [] + + async def mutate(repository, number, blocker_repository, blocker_number, remove=False): + calls.append((repository, number, blocker_repository, blocker_number, remove)) + return { + "repository": repository, "number": number, "dependencies_available": True, + "dependencies": [] if remove else [{"repository": blocker_repository, "number": blocker_number}], + } + + monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.patch( + "/api/v1/repos/stackchain/dashboard/issues/17/blockers", + headers={"Idempotency-Key": "offline-blocker-1"}, + json={"repository": "stackchain/api", "number": 9, "present": False}, + ) + + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert response.json()["dependencies"] == [] + assert calls == [("stackchain/dashboard", 17, "stackchain/api", 9, True)] + + @pytest.mark.anyio async def test_assigned_issue_blocker_route_rejects_invalid_candidate(monkeypatch): async def mutate(*_args, **_kwargs): @@ -2037,14 +2063,49 @@ async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmat }] +@pytest.mark.anyio +async def test_gitea_dependency_retry_returns_canonical_state_without_duplicate_mutation(): + mutations = [] + dependency = { + "number": 9, "state": "open", "title": "Restore API", + "repository": {"full_name": "stackchain/api"}, + "html_url": "https://forge.example/stackchain/api/issues/9", + } + + async def handler(request): + path = request.url.path + if path == "/api/v1/user": + return httpx.Response(200, json={"login": "timmy"}) + if path == "/api/v1/repos/stackchain/dashboard/issues/17": + return httpx.Response(200, json={ + "number": 17, "state": "open", "assignees": [{"login": "timmy"}], + }) + if path == "/api/v1/repos/stackchain/api/issues/9": + return httpx.Response(200, json={"number": 9, "state": "open"}) + if request.method == "GET" and path.endswith("/dependencies"): + return httpx.Response(200, json=[dependency]) + if path.endswith("/dependencies"): + mutations.append(request.method) + return httpx.Response(201, json={}) + raise AssertionError((request.method, path)) + + gitea_proxy.start_client(transport=httpx.MockTransport(handler)) + try: + result = await gitea_proxy.mutate_assigned_issue_dependency( + "stackchain/dashboard", 17, "stackchain/api", 9 + ) + finally: + await gitea_proxy.stop_client() + + assert mutations == [] + assert result["dependencies"][0]["number"] == 9 + + @pytest.mark.anyio @pytest.mark.parametrize("candidate,blocker_repository,blocker_number,existing", [ ({"number": 17, "state": "open"}, "stackchain/dashboard", 17, []), ({"number": 9, "state": "closed"}, "stackchain/api", 9, []), ({"number": 9, "state": "open", "pull_request": {}}, "stackchain/api", 9, []), - ({"number": 9, "state": "open"}, "stackchain/api", 9, [ - {"number": 9, "state": "open", "repository": {"full_name": "stackchain/api"}}, - ]), ]) async def test_gitea_dependency_rejects_invalid_candidates( candidate, blocker_repository, blocker_number, existing diff --git a/tests/test_offline_issue_blocker.py b/tests/test_offline_issue_blocker.py new file mode 100644 index 0000000..8228ddf --- /dev/null +++ b/tests/test_offline_issue_blocker.py @@ -0,0 +1,78 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from tests.dashboard_bundle import dashboard + + +MODULE = Path(__file__).parents[1] / "frontend" / "offline-issue-blocker.js" + + +def run_node(script: str): + completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + return json.loads(completed.stdout) + + +def test_offline_blocker_reports_queued_only_after_durable_admission(): + script = f""" +const createOfflineIssueBlocker=require({json.dumps(str(MODULE))}); +const events=[]; +const queue=createOfflineIssueBlocker({{ + createOperationId:()=> 'blocker-op', + enqueueDurably:async message=>{{events.push(['admit',message]);return{{item:message,durability:'background'}};}}, + onQueued:(item,blocker,present,admission)=>events.push(['queued',item.number,blocker.number,present,admission.durability]), +}}); +(async()=>{{ + const result=await queue( + {{repository:'stackchain/dashboard',number:17}}, + {{repository:'stackchain/api',number:9,title:'Restore API'}}, + false + ); + process.stdout.write(JSON.stringify({{events,result}})); +}})(); +""" + output = run_node(script) + + assert output["events"] == [ + ["admit", { + "kind": "issue-blocker", "repository": "stackchain/dashboard", "number": 17, + "blockerRepository": "stackchain/api", "blockerNumber": 9, + "present": False, "operationId": "blocker-op", + }], + ["queued", 17, 9, False, "background"], + ] + assert output["result"]["admission"]["durability"] == "background" + + +def test_offline_blocker_does_not_report_queued_when_durable_admission_fails(): + script = f""" +const createOfflineIssueBlocker=require({json.dumps(str(MODULE))}); +let queued=false; +const queue=createOfflineIssueBlocker({{ + enqueueDurably:async()=>{{throw new Error('IndexedDB unavailable');}}, + onQueued:()=>queued=true, +}}); +queue({{repository:'o/r',number:1}},{{repository:'o/b',number:2}},true) + .then(()=>process.stdout.write(JSON.stringify({{queued}}))) + .catch(error=>process.stdout.write(JSON.stringify({{queued,error:error.message}}))); +""" + output = run_node(script) + + assert output == {"queued": False, "error": "IndexedDB unavailable"} + + +@pytest.mark.anyio +async def test_saved_issue_exposes_offline_blocker_queue_in_mobile_sheet(): + html = await dashboard() + dashboard_js = (MODULE.parent / "dashboard.js").read_text() + service_worker = (MODULE.parent / "service-worker.js").read_text() + + assert '' in html + assert "BASE + 'static/offline-issue-blocker.js'" in service_worker + assert "const queueOfflineIssueBlocker = createOfflineIssueBlocker({" in dashboard_js + assert "qs('#manage-issue-blockers').hidden = !assignedIssue;" in dashboard_js + assert "await queueOfflineIssueBlocker(item, blocker, !remove);" in dashboard_js + assert "Blocker removal queued. Today remains blocked until Stackchain confirms delivery." in dashboard_js + assert "const candidates = selectedIssueOffline ? workSession.items()" in dashboard_js diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 1307134..36613ec 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -429,6 +429,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/issue-outbox.js", "/dashboard/static/authored-outbox.js", "/dashboard/static/offline-issue-close.js", + "/dashboard/static/offline-issue-blocker.js", "/dashboard/static/notification-read-outbox.js", "/dashboard/static/offline-work.js", "/dashboard/static/offline-today.js",