import json import subprocess from pathlib import Path import pytest from tests.dashboard_bundle import dashboard ROOT = Path(__file__).parents[1] UPDATE_FOLLOW_UP = ROOT / "frontend" / "update-follow-up.js" CREATE_ISSUE_SHEET = ROOT / "frontend" / "create-issue-sheet.js" def run_node(script: str) -> dict: result = subprocess.run( ["node", "-e", script], capture_output=True, text=True, timeout=10 ) assert result.returncode == 0, result.stderr return json.loads(result.stdout) def test_follow_up_derives_repository_editable_title_and_canonical_context(): script = f""" const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))}); const controller = createFollowUp(); process.stdout.write(JSON.stringify(controller.draft({{ repository: 'stackchain/stackchain-dashboard', title: 'Fix mobile queue', subject_type: 'Pull', url: 'https://forge.example/git/stackchain/stackchain-dashboard/pulls/42', latest_comment: {{ body: 'Please preserve the unread state. Ship this on mobile.' }} }}))); """ assert run_node(script) == { "repository": "stackchain/stackchain-dashboard", "title": "Follow up: Fix mobile queue", "body": ( "Source: https://forge.example/git/stackchain/stackchain-dashboard/pulls/42\n\n" "Latest context:\n> Please preserve the unread state. Ship this on mobile." ), } def test_follow_up_rejects_unsafe_repository_and_noncanonical_source_url(): script = f""" const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))}); const controller = createFollowUp(); process.stdout.write(JSON.stringify(controller.draft({{ repository: '../admin', title: '', url: 'javascript:alert(1)', latest_comment: {{body: 'x'.repeat(12000)}} }}))); """ output = run_node(script) assert output["repository"] == "" assert output["title"] == "Follow up: " assert "javascript:" not in output["body"] assert len(output["body"]) <= 9500 def test_follow_up_continuation_is_account_bound_and_survives_reload(): script = f""" const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))}); 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 = 'timmy'; const first = createFollowUp({{storage, getLogin:()=>login}}); const staged = first.stageSource({{ notification_id: 42, repository:'stackchain/stackchain-dashboard', number:665, title:'Unread follow-up source', kind:'update', has_update:true }}); const restored = createFollowUp({{storage, getLogin:()=>login}}).source(); login = 'alexander'; const isolated = createFollowUp({{storage, getLogin:()=>login}}).source(); process.stdout.write(JSON.stringify({{staged, restored, isolated}})); """ output = run_node(script) assert output["staged"]["notification_id"] == 42 assert output["restored"] == output["staged"] assert output["isolated"] is None def test_follow_up_completion_admits_issue_before_durable_read_and_advances_once(): script = f""" const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))}); 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) }}; const order = []; const controller = createFollowUp({{storage, getLogin:()=> 'timmy'}}); controller.stageSource({{notification_id:42, repository:'o/r', number:7, title:'Source'}}); (async () => {{ const result = await controller.complete({{ admit:async()=>{{ order.push('issue'); return {{item:{{id:'issue:1'}}}}; }}, queueRead:async id=>{{ order.push('read:' + id); return {{item:{{id:'read:42'}}}}; }}, advance:async source=>{{ order.push('next:' + source.notification_id); return true; }}, }}); process.stdout.write(JSON.stringify({{order, result, pending:controller.source()}})); }})(); """ output = run_node(script) assert output == { "order": ["issue", "read:42", "next:42"], "result": {"admission": {"item": {"id": "issue:1"}}, "advanced": True}, "pending": None, } def test_follow_up_completion_preserves_source_when_issue_admission_fails(): script = f""" const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))}); 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) }}; const order = []; const controller = createFollowUp({{storage, getLogin:()=> 'timmy'}}); controller.stageSource({{notification_id:42, repository:'o/r', number:7, title:'Source'}}); (async () => {{ try {{ await controller.complete({{ admit:async()=>{{ order.push('issue'); throw new Error('disk full'); }}, queueRead:async()=>order.push('read'), advance:async()=>order.push('next'), }}); }} catch (error) {{ process.stdout.write(JSON.stringify({{order, error:error.message, pending:controller.source()}})); }} }})(); """ output = run_node(script) assert output["order"] == ["issue"] assert output["error"] == "disk full" assert output["pending"]["notification_id"] == 42 def test_follow_up_staging_never_silently_overwrites_an_existing_capture(): script = f""" const createCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))}); 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) }}; const capture = createCapture({{fetchJson:async()=>[], storage}}); capture.saveDraft({{repository:'o/existing', title:'Existing draft', body:'Keep me'}}); const state = capture.stageFollowUp({{repository:'o/new', title:'Follow up', body:'Source: https://example.test/1'}}); const before = capture.loadDraft(); const accepted = capture.acceptFollowUp(); process.stdout.write(JSON.stringify({{state, before, accepted, pending:capture.pendingFollowUp()}})); """ assert run_node(script) == { "state": {"status": "conflict"}, "before": { "repository": "o/existing", "title": "Existing draft", "body": "Keep me", "labelIds": [], }, "accepted": { "repository": "o/new", "title": "Follow up", "body": "Source: https://example.test/1", "labelIds": [], }, "pending": None, } @pytest.mark.anyio async def test_update_sheet_wires_phone_safe_follow_up_without_marking_read(): html = await dashboard() assert 'name="stackchain-feature-issue-capture"' in html assert 'id="create-update-follow-up"' in html assert '>Create follow-up' in html assert "issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail))" in html assert "qs('#create-update-follow-up').addEventListener('click'" in html assert "markNotificationRead" not in html.split("qs('#create-update-follow-up').addEventListener('click'", 1)[1].split("});", 1)[0] assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html @pytest.mark.anyio async def test_follow_up_capture_offers_durable_create_and_next_flow(): html = await dashboard() assert 'id="create-follow-up-next"' in html assert '>Create follow-up & next' in html assert "updateFollowUp.stageSource(source.item)" in html assert "event.submitter?.id === 'create-follow-up-next'" in html assert "queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId)" in html assert "advance: source => notificationReader.acceptReadAndNext(lastMyWork, source)" in html assert ".create-issue-actions button { min-height:44px;" in html