import json import subprocess from pathlib import Path from tests.dashboard_bundle import dashboard_bundle_text OVERLAY_HISTORY = Path(__file__).parents[1] / "frontend" / "task-overlay-history.js" def test_task_overlay_history_unwinds_preview_search_and_dashboard_without_duplicate_entries(): script = f""" const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))}); const listeners = {{}}; const location = {{ hash:'', pathname:'/dashboard/' }}; const stack = [{{ page:'dashboard' }}]; let cursor = 0; const changes = []; const history = {{ get state() {{ return stack[cursor]; }}, pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }}, back() {{ if (cursor > 0) cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }}, }}; const controller = createTaskOverlayHistory({{ history, location, eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }}, onChange(kind, previous) {{ changes.push([kind, previous]); }}, }}); controller.start(); controller.open('search'); controller.open('search'); controller.open('search-preview'); controller.close(); controller.close(); process.stdout.write(JSON.stringify({{changes, stack, cursor, current:controller.current()}})); """ result = subprocess.run( ["node", "-e", script], capture_output=True, text=True ) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "changes": [ ["search", None], ["search-preview", "search"], ["search", "search-preview"], [None, "search"], ], "stack": [ {"page": "dashboard"}, {"page": "dashboard", "taskOverlay": "search"}, {"page": "dashboard", "taskOverlay": "search-preview"}, ], "cursor": 0, "current": None, } def test_task_overlay_history_rejects_unknown_states_and_preserves_existing_history_state(): script = f""" const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))}); const pushed = []; const history = {{ state: {{ workRoute:'#/my-work/issue/stackchain/api/7' }}, pushState(state) {{ this.state = state; pushed.push(state); }}, back() {{}}, }}; const controller = createTaskOverlayHistory({{ history, location:{{}}, eventTarget:{{addEventListener() {{}}}}, onChange() {{}}, }}); const invalid = controller.open('settings'); const valid = controller.open('new'); process.stdout.write(JSON.stringify({{invalid, valid, pushed}})); """ result = subprocess.run( ["node", "-e", script], capture_output=True, text=True ) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "invalid": False, "valid": True, "pushed": [{ "workRoute": "#/my-work/issue/stackchain/api/7", "taskOverlay": "new", }], } def test_task_overlay_history_can_leave_an_overlay_for_a_work_route_transition(): script = f""" const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))}); const changes = []; const history = {{ state: {{ page:'dashboard' }}, pushState(state) {{ this.state = state; }}, replaceState(state) {{ this.state = state; }}, back() {{ throw new Error('route transitions must not race history.back'); }}, }}; const controller = createTaskOverlayHistory({{ history, eventTarget:{{addEventListener() {{}}}}, onChange(kind, previous) {{ changes.push([kind, previous]); }}, }}); controller.open('find'); const left = controller.leave(); process.stdout.write(JSON.stringify({{left, state:history.state, current:controller.current(), changes}})); """ result = subprocess.run( ["node", "-e", script], capture_output=True, text=True ) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "left": True, "state": {"page": "dashboard"}, "current": None, "changes": [["find", None], [None, "find"]], } def test_plan_today_uses_one_history_layer_and_can_leave_after_save(): script = f""" const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))}); const listeners = {{}}; const changes = []; const stack = [{{ page:'dashboard' }}]; let cursor = 0; const history = {{ get state() {{ return stack[cursor]; }}, pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }}, replaceState(state) {{ stack[cursor] = state; }}, back() {{ cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }}, }}; const controller = createTaskOverlayHistory({{ history, eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }}, onChange(kind, previous) {{ changes.push([kind, previous]); }}, }}); controller.start(); const opened = controller.open('plan-today'); const repeated = controller.open('plan-today'); const left = controller.leave(); process.stdout.write(JSON.stringify({{ opened, repeated, left, stack, cursor, current:controller.current(), changes, }})); """ result = subprocess.run( ["node", "-e", script], capture_output=True, text=True ) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "opened": True, "repeated": True, "left": True, "stack": [ {"page": "dashboard"}, {"page": "dashboard"}, ], "cursor": 1, "current": None, "changes": [["plan-today", None], [None, "plan-today"]], } def test_plan_today_preview_is_a_nested_browser_history_layer(): script = f""" const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))}); const listeners = {{}}; const changes = []; const stack = [{{ page:'dashboard' }}]; let cursor = 0; const history = {{ get state() {{ return stack[cursor]; }}, pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }}, back() {{ cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }}, }}; const controller = createTaskOverlayHistory({{ history, eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }}, onChange(kind, previous) {{ changes.push([kind, previous]); }}, }}); controller.start(); controller.open('plan-today'); const previewed = controller.open('plan-today-preview'); controller.close(); process.stdout.write(JSON.stringify({{previewed, cursor, current:controller.current(), changes}})); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == { "previewed": True, "cursor": 1, "current": "plan-today", "changes": [ ["plan-today", None], ["plan-today-preview", "plan-today"], ["plan-today", "plan-today-preview"], ], } def test_dashboard_routes_mobile_task_overlays_through_browser_history(): html = dashboard_bundle_text() assert '' in html assert "createTaskOverlayHistory({" in html assert "taskOverlayHistory.open('new')" in html assert "taskOverlayHistory.open('find')" in html assert "taskOverlayHistory.open('search', { scope:currentSearchScope() })" in html assert "taskOverlayHistory.open('search-preview')" in html assert "taskOverlayHistory.close()" in html assert "saveIssueCaptureDraft();" in html assert "history.replaceState(history.state || {}, '', cleanUrl)" in html def test_search_history_serializes_bounded_query_and_stable_preview_identity(): script = f""" const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))}); const pushed = []; const replaced = []; const location = {{ pathname:'/dashboard/', search:'?keep=1', hash:'' }}; const history = {{ state: {{ page:'dashboard' }}, pushState(state, title, url) {{ this.state = state; pushed.push([state, url]); }}, replaceState(state, title, url) {{ this.state = state; replaced.push([state, url]); }}, back() {{}}, }}; const controller = createTaskOverlayHistory({{ history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}}, }}); controller.open('search', {{ query:' release blocker ' }}); controller.update({{ query:'release candidate' }}); controller.open('search-preview', {{ query:'release candidate', preview:{{kind:'issue', repository:'stackchain/api', number:123, title:'must not be serialized', body:'secret'}}, }}); process.stdout.write(JSON.stringify({{pushed, replaced, current:controller.currentState()}})); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr payload = json.loads(result.stdout) assert len(payload["pushed"]) == 2 assert len(payload["replaced"]) == 1 assert payload["pushed"][0][1] == "/dashboard/?keep=1&search=release+blocker" assert payload["replaced"][0][1] == "/dashboard/?keep=1&search=release+candidate" assert payload["pushed"][1][1] == ( "/dashboard/?keep=1&search=release+candidate&preview=issue%3Astackchain%2Fapi%3A123" ) assert payload["current"] == { "kind": "search-preview", "query": "release candidate", "preview": {"kind": "issue", "repository": "stackchain/api", "number": 123}, } def test_search_history_restores_direct_url_and_rejects_malformed_or_oversized_state(): script = f""" const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))}); function restore(search) {{ const changes = []; const location = {{ pathname:'/dashboard/', search, hash:'' }}; const history = {{ state:null, replaceState(state) {{ this.state = state; }}, back() {{}} }}; const controller = createTaskOverlayHistory({{ history, location, eventTarget:{{addEventListener() {{}}}}, onChange(kind, previous, detail) {{ changes.push([kind, previous, detail]); }}, }}); controller.start(); return {{ state:controller.currentState(), changes }}; }} process.stdout.write(JSON.stringify({{ valid:restore('?search=release+blocker&preview=pull%3Astackchain%2Fapi%3A9'), malformed:restore('?search=ok&preview=issue%3Ainvalid%3A0'), oversized:restore('?search=' + 'x'.repeat(201)), }})); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr payload = json.loads(result.stdout) expected = { "kind": "search-preview", "query": "release blocker", "preview": {"kind": "pull", "repository": "stackchain/api", "number": 9}, } assert payload["valid"]["state"] == expected assert payload["valid"]["changes"] == [["search-preview", None, expected]] assert payload["malformed"]["state"] == {"kind": "search", "query": "ok"} assert payload["oversized"]["state"] == {"kind": None} def test_search_history_round_trips_bounded_type_status_and_repository_scope(): script = f""" const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))}); function restore(search) {{ const location = {{pathname:'/dashboard/', search, hash:''}}; const history = {{state:null, replaceState(state, title, url) {{this.state=state; this.url=url;}}, back() {{}}}}; const controller = createTaskOverlayHistory({{ history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}}, }}); return {{state:controller.currentState(), url:history.url}}; }} const location = {{pathname:'/dashboard/', search:'', hash:''}}; const history = {{state:null, pushState(state,title,url) {{this.state=state; this.url=url;}}, replaceState() {{}}, back() {{}}}}; const controller = createTaskOverlayHistory({{ history, location, eventTarget:{{addEventListener() {{}}}}, onChange() {{}}, }}); controller.open('search', {{query:'mobile', scope:{{kind:'pull',state:'open',repository:'stackchain/api'}}}}); process.stdout.write(JSON.stringify({{ opened:controller.currentState(), url:history.url, valid:restore('?search=mobile&search_kind=issue&search_state=closed&search_repository=stackchain%2Fapi'), invalid:restore('?search=mobile&search_kind=script&search_state=secret&search_repository=stackchain%2Fapi%2Fprivate'), }})); """ result = subprocess.run(["node", "-e", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr payload = json.loads(result.stdout) assert payload["opened"] == { "kind": "search", "query": "mobile", "scope": { "kind": "pull", "state": "open", "repository": "stackchain/api" } } assert payload["url"] == ( "/dashboard/?search=mobile&search_kind=pull&search_state=open&" "search_repository=stackchain%2Fapi" ) assert payload["valid"]["state"]["scope"] == { "kind": "issue", "state": "closed", "repository": "stackchain/api" } assert payload["invalid"]["state"]["scope"] == { "kind": "all", "state": "all", "repository": "" } def test_dashboard_persists_search_query_and_restores_canonical_preview(): html = dashboard_bundle_text() assert "taskOverlayHistory.update({ query:e.target.value })" in html assert "scope:currentSearchScope(), preview:item.result" in html assert "detail?.query" in html assert "searchPreview.open(detail.preview).catch" in html