From 96e11812cefc0cb522006c043d03b1753fe34634 Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Mon, 5 Oct 2026 00:49:28 +0000 Subject: [PATCH] fix(dashboard): keep shell during menu transitions Replace document navigation with native Streamlit state transitions, preserve dashboard contracts, and add regression plus authenticated browser evidence for Issue #9. --- dashboard/app.py | 20 +- dashboard/common.py | 50 +- .../README.md | 47 + .../browser-observations.json | 988 ++++++++++++++++++ .../final-state.jpg | Bin 0 -> 34297 bytes .../manifest.json | 61 ++ docs/interface-contract.md | 38 + docs/project-structure.md | 21 +- .../req-002_dashboard-menu-transition.md | 258 +++++ docs/runtime-flow.md | 30 + scripts/streamlit_browser_evidence.py | 319 +++++- tests/test_dashboard_menu_transition.py | 219 ++++ tests/test_streamlit_browser_evidence.py | 166 +++ 13 files changed, 2166 insertions(+), 51 deletions(-) create mode 100644 docs/evidence/issue-9-dashboard-menu-transition/README.md create mode 100644 docs/evidence/issue-9-dashboard-menu-transition/browser-observations.json create mode 100644 docs/evidence/issue-9-dashboard-menu-transition/final-state.jpg create mode 100644 docs/evidence/issue-9-dashboard-menu-transition/manifest.json create mode 100644 docs/requirements/req-002_dashboard-menu-transition.md create mode 100644 tests/test_dashboard_menu_transition.py create mode 100644 tests/test_streamlit_browser_evidence.py diff --git a/dashboard/app.py b/dashboard/app.py index 1a4d5b7..6b231cf 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -12,7 +12,15 @@ if __package__ in (None, ""): st.set_page_config(page_title="Jarca Ledger", page_icon="๐Ÿ’ฐ", layout="wide") -from dashboard.common import clear_cache, get_db_path, get_transfer_review_items, inject_app_css, render_sidebar_nav # noqa: F401 +from dashboard.common import ( + clear_cache, + get_db_path, + get_transfer_review_items, # noqa: F401 + inject_app_css, + render_dashboard_body, + render_sidebar_nav, + validate_dashboard_page, +) # noqa: F401 from dashboard.authentication import require_dashboard_authentication from dashboard.page_views.accounts import page_accounts from dashboard.page_views.assets import page_assets @@ -32,15 +40,15 @@ def main() -> None: db_path = get_db_path() inject_app_css() pages = ["์›”์ •์‚ฐ", "์š”์•ฝ", "์ž์‚ฐ/๋งˆ๊ฐ", "๊ฑฐ๋ž˜๋‚ด์—ญ", "ํ™•์ธํ•„์š” ํ", "๋ฏธ๋ถ„๋ฅ˜", "๋ถ„๋ฅ˜ ๊ทœ์น™", "์นดํ…Œ๊ณ ๋ฆฌ", "๊ณ„์ขŒ", "Import"] - query_page = st.query_params.get("page", "์š”์•ฝ") - page = query_page if query_page in pages else "์š”์•ฝ" - st.session_state["page"] = page + query_page = st.query_params.get("page") + page = validate_dashboard_page(query_page, pages) + st.session_state["dashboard_page"] = page with st.sidebar: st.markdown("## ๐Ÿ’ฐ Jarca Ledger") st.caption("๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”") st.markdown('
๋ฉ”๋‰ด
', unsafe_allow_html=True) - render_sidebar_nav(pages, page) + page = render_sidebar_nav(pages, page) if st.button("์ƒˆ๋กœ๊ณ ์นจ", key="refresh_button", use_container_width=True): clear_cache() st.rerun() @@ -56,7 +64,7 @@ def main() -> None: "๊ณ„์ขŒ": page_accounts, "Import": page_imports, } - dispatch[page](db_path) + render_dashboard_body(page, lambda: dispatch[page](db_path)) if __name__ == "__main__": diff --git a/dashboard/common.py b/dashboard/common.py index 60abf90..23c66a5 100644 --- a/dashboard/common.py +++ b/dashboard/common.py @@ -4,7 +4,6 @@ import sqlite3 from datetime import datetime from html import escape from typing import Any -from urllib.parse import quote import importlib import inspect from zoneinfo import ZoneInfo @@ -314,6 +313,9 @@ def inject_app_css() -> None: border-color: rgba(37, 99, 235, 0.48); box-shadow: 0 10px 22px rgba(15, 23, 42, 0.10); } + .st-key-dashboard_body:has(.st-key-dashboard_loading [data-testid="stAlert"]) .st-key-dashboard_content { + display: none; + } .jarca-card { border: 1px solid rgba(226, 232, 240, 0.86); border-radius: 22px; @@ -355,13 +357,45 @@ def render_section_label(icon: str, label: str) -> None: st.markdown(f'
{escape(icon)}{escape(label)}
', unsafe_allow_html=True) -def render_sidebar_nav(pages: list[str], current_page: str) -> None: - items = [] - for name in pages: - active = " active" if name == current_page else "" - href = f"?page={quote(name)}" - items.append(f'{escape(name)}') - st.markdown(f'
{"".join(items)}
', unsafe_allow_html=True) +def validate_dashboard_page(page: str | None, pages: list[str]) -> str: + return page if page in pages else "์š”์•ฝ" + + +def _sync_dashboard_page_query() -> None: + st.query_params["page"] = st.session_state["dashboard_page"] + + +def render_sidebar_nav(pages: list[str], current_page: str) -> str: + if "dashboard_page" not in st.session_state: + st.session_state["dashboard_page"] = current_page + return st.radio( + "๋ฉ”๋‰ด", + pages, + key="dashboard_page", + label_visibility="collapsed", + on_change=_sync_dashboard_page_query, + ) + + +def render_dashboard_body(page: str, renderer) -> None: + body = st.empty() + body.empty() + try: + with body.container(): + with st.container(key="dashboard_body"): + loading = st.empty() + with loading.container(): + with st.container(key="dashboard_loading"): + st.info(f"{page} ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘โ€ฆ") + with st.container(key="dashboard_content"): + renderer() + loading.empty() + except Exception: + body.empty() + with body.container(): + st.error("ํŽ˜์ด์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.") + if st.button("๋‹ค์‹œ ์‹œ๋„", key=f"retry_{page}"): + st.rerun() def parse_optional_int(value: str, field_name: str) -> int | None: diff --git a/docs/evidence/issue-9-dashboard-menu-transition/README.md b/docs/evidence/issue-9-dashboard-menu-transition/README.md new file mode 100644 index 0000000..d3d2a29 --- /dev/null +++ b/docs/evidence/issue-9-dashboard-menu-transition/README.md @@ -0,0 +1,47 @@ +# Issue #9 authenticated browser evidence + +This directory binds the dashboard menu-transition acceptance evidence to the exact working-tree files listed in `manifest.json`. + +## Captured transition + +- **Path:** `์š”์•ฝ` โ†’ `๋ถ„๋ฅ˜ ๊ทœ์น™` +- **Input:** native Streamlit menu click in an authenticated HTTPS browser session +- **Fixture:** generated synthetic SQLite database only +- **Sampling:** 35 DOM/style frames at 50 ms intervals +- **Viewport:** 1280 ร— 720 +- **Browser:** Headless Chrome 154 on Linux x86_64 +- **Fixture host:** Linux aarch64, Python 3.13.5, Streamlit 1.58.0, Uvicorn 0.49.0 + +## Verified browser observations + +All eight manifest checks are true: + +- the HTML document marker stayed identical; +- the sidebar marker stayed identical; +- the page background stayed identical; +- no post-click full-document `load` event occurred; +- the target loading state was observed; +- the previous `์š”์•ฝ` body was absent in every loading frame; +- the unrelated `fixture=preserved` query key survived; +- final URL, selected menu, target body, and cleared loading state agreed. + +`browser-observations.json` contains every sampled frame, body hashes, URL state, history events, document/sidebar markers, computed background, runtime metadata, and explicit limitations. `final-state.jpg` is the final visible browser state; it contains no credential or secret. + +## Recheck + +Run the exact validator recorded in `manifest.json`. The captured result was: + +```text +exit code: 0 +PASS source_hashes=5 artifact_hashes=2 checks=8/8 frames=35 full_document_loads=0 +``` + +The browser action was executed through `mcp__playwright__browser_run_code_unsafe`. That API reports success/error rather than a numeric process exit code; this boundary is explicit in the manifest instead of being represented as a fabricated exit status. + +## Evidence boundaries + +- This is synthetic-fixture browser evidence, not operating-ledger evidence. +- A fixture-only external wrapper delayed the rules renderer by two seconds so the loading interval could be sampled. It did not alter repository source. +- A temporary HTTPS ingress adapter supplied trusted proxy scheme/Host metadata. Repository authentication and same-origin enforcement remained active. +- The tunnel URL is intentionally ephemeral and is retained in the frame record only for provenance. +- The screenshot supplements, but does not replace, the machine-readable continuous-frame observations. diff --git a/docs/evidence/issue-9-dashboard-menu-transition/browser-observations.json b/docs/evidence/issue-9-dashboard-menu-transition/browser-observations.json new file mode 100644 index 0000000..c84762f --- /dev/null +++ b/docs/evidence/issue-9-dashboard-menu-transition/browser-observations.json @@ -0,0 +1,988 @@ +{ + "schema": "jarca.issue9.browser-transition.v1", + "source": "authenticated Playwright browser runner through temporary HTTPS fixture ingress", + "from_page": "์š”์•ฝ", + "to_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "pre": { + "elapsed_ms": 0, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EC%9A%94%EC%95%BD&fixture=preserved", + "query_page": "์š”์•ฝ", + "sibling_value": "preserved", + "selected": "์š”์•ฝ", + "loading": false, + "previous_body_visible": true, + "target_body_visible": false, + "body_sha256": "64d8cf1234afc84635c58b9ec964b2573fd8bea12dfd107e82086cc20f6b16f3", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + "frames": [ + { + "elapsed_ms": 134, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EC%9A%94%EC%95%BD&fixture=preserved", + "query_page": "์š”์•ฝ", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": false, + "previous_body_visible": true, + "target_body_visible": false, + "body_sha256": "64d8cf1234afc84635c58b9ec964b2573fd8bea12dfd107e82086cc20f6b16f3", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 296, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EC%9A%94%EC%95%BD&fixture=preserved", + "query_page": "์š”์•ฝ", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": false, + "previous_body_visible": true, + "target_body_visible": false, + "body_sha256": "64d8cf1234afc84635c58b9ec964b2573fd8bea12dfd107e82086cc20f6b16f3", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 374, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EC%9A%94%EC%95%BD&fixture=preserved", + "query_page": "์š”์•ฝ", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": false, + "previous_body_visible": true, + "target_body_visible": false, + "body_sha256": "64d8cf1234afc84635c58b9ec964b2573fd8bea12dfd107e82086cc20f6b16f3", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 442, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EC%9A%94%EC%95%BD&fixture=preserved", + "query_page": "์š”์•ฝ", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": false, + "previous_body_visible": true, + "target_body_visible": false, + "body_sha256": "64d8cf1234afc84635c58b9ec964b2573fd8bea12dfd107e82086cc20f6b16f3", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 520, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EC%9A%94%EC%95%BD&fixture=preserved", + "query_page": "์š”์•ฝ", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": false, + "previous_body_visible": true, + "target_body_visible": false, + "body_sha256": "64d8cf1234afc84635c58b9ec964b2573fd8bea12dfd107e82086cc20f6b16f3", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 600, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EC%9A%94%EC%95%BD&fixture=preserved", + "query_page": "์š”์•ฝ", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": false, + "previous_body_visible": true, + "target_body_visible": false, + "body_sha256": "64d8cf1234afc84635c58b9ec964b2573fd8bea12dfd107e82086cc20f6b16f3", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 669, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EC%9A%94%EC%95%BD&fixture=preserved", + "query_page": "์š”์•ฝ", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": false, + "previous_body_visible": true, + "target_body_visible": false, + "body_sha256": "64d8cf1234afc84635c58b9ec964b2573fd8bea12dfd107e82086cc20f6b16f3", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 739, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EC%9A%94%EC%95%BD&fixture=preserved", + "query_page": "์š”์•ฝ", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": false, + "previous_body_visible": true, + "target_body_visible": false, + "body_sha256": "64d8cf1234afc84635c58b9ec964b2573fd8bea12dfd107e82086cc20f6b16f3", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 816, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": false, + "previous_body_visible": true, + "target_body_visible": false, + "body_sha256": "64d8cf1234afc84635c58b9ec964b2573fd8bea12dfd107e82086cc20f6b16f3", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 1396, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": false, + "previous_body_visible": true, + "target_body_visible": false, + "body_sha256": "64d8cf1234afc84635c58b9ec964b2573fd8bea12dfd107e82086cc20f6b16f3", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 1580, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 1654, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 1721, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 1793, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 1860, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 1926, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 1993, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 2061, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 2130, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 2197, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 2274, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 2341, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 2410, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 2478, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 2544, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 2610, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 2681, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 2753, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 2826, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 2912, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 3286, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 4054, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": true, + "previous_body_visible": false, + "target_body_visible": false, + "body_sha256": "42fe6bb021f60cda14cc750a3bf7d1c58c9801323a37219d221d4e1345da81bd", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 5973, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": false, + "previous_body_visible": false, + "target_body_visible": true, + "body_sha256": "f5c040f5f42490cba765987c8b849ea41ee248ed1b403f2dc890b2b27a73dad9", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 6065, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": false, + "previous_body_visible": false, + "target_body_visible": true, + "body_sha256": "f5c040f5f42490cba765987c8b849ea41ee248ed1b403f2dc890b2b27a73dad9", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + { + "elapsed_ms": 6134, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": false, + "previous_body_visible": false, + "target_body_visible": true, + "body_sha256": "f5c040f5f42490cba765987c8b849ea41ee248ed1b403f2dc890b2b27a73dad9", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + } + ], + "post": { + "elapsed_ms": 6134, + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "query_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "sibling_value": "preserved", + "selected": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "loading": false, + "previous_body_visible": false, + "target_body_visible": true, + "body_sha256": "f5c040f5f42490cba765987c8b849ea41ee248ed1b403f2dc890b2b27a73dad9", + "document_marker": "issue9-document-marker", + "sidebar_marker": "issue9-sidebar-marker", + "background": "rgb(255, 255, 255)", + "history_events": [ + { + "type": "pushState", + "url": "/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693754 + } + ], + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + "same_document_events": [ + { + "kind": "same-document-url-change", + "url": "https://d54e2eeaa8e50e2b-211-109-75-114.serveousercontent.com/?page=%EB%B6%84%EB%A5%98+%EA%B7%9C%EC%B9%99&fixture=preserved", + "at": 1791130693813 + } + ], + "document_load_events": [], + "checks": { + "document_identity_stable": true, + "sidebar_identity_stable": true, + "background_stable": true, + "zero_full_document_loads": true, + "loading_observed": true, + "previous_body_absent_while_loading": true, + "query_sibling_preserved": true, + "final_agreement": true + }, + "runtime": { + "captured_at": "2026-10-04T16:18:18.832Z", + "interval_ms": 50, + "frame_count": 35, + "viewport": { + "width": 1280, + "height": 720 + }, + "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36", + "architecture": "Linux x86_64" + }, + "limitations": [ + "Synthetic SQLite fixture only; no operating ledger data.", + "Rules renderer delayed two seconds by an external fixture-only module wrapper.", + "Temporary HTTPS ingress adapter normalized scheme/Host metadata; repository authentication checks remained active.", + "Playwright same-document URL events are listed separately from full load events." + ] +} \ No newline at end of file diff --git a/docs/evidence/issue-9-dashboard-menu-transition/final-state.jpg b/docs/evidence/issue-9-dashboard-menu-transition/final-state.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f2db8b21a77abd60446c5e5229cb95e69ba3de62 GIT binary patch literal 34297 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 RokxfMe|G%dO3wZ={2!FT^P&I% literal 0 HcmV?d00001 diff --git a/docs/evidence/issue-9-dashboard-menu-transition/manifest.json b/docs/evidence/issue-9-dashboard-menu-transition/manifest.json new file mode 100644 index 0000000..5205b6a --- /dev/null +++ b/docs/evidence/issue-9-dashboard-menu-transition/manifest.json @@ -0,0 +1,61 @@ +{ + "schema": "jarca.issue9.evidence-manifest.v1", + "issue": "jsfamily/jarca-ledger-mcp#9", + "captured_at": "2026-10-04T16:18:18.832Z", + "source": { + "base_revision": "ffd943fe65875e67148c28ba44bae32918e338c9", + "working_tree": true, + "files_sha256": { + "dashboard/app.py": "4c7b3a6264b4fb4ae0e445ddc04c625b295443816b198aa5a60615ac44846ff2", + "dashboard/common.py": "9a0b9dbb4ca88f27757fc293173821c48a56a30bc23f16b0f24731f246411c5a", + "scripts/streamlit_browser_evidence.py": "766beefb0d861ea6b08561c3aaa0c6d8915c9166e4221725e27b5e26cea63798", + "tests/test_dashboard_menu_transition.py": "d23c35d88f6289b7c7e84a271df6ff3f6ff1f25a3a885aa696cc93db0b37035e", + "tests/test_streamlit_browser_evidence.py": "b922593f96da5af9a0b1fe33cacbe3abe139ef00554e7c535fe7cc8f24679b23" + } + }, + "execution": { + "browser_tool": "mcp__playwright__browser_run_code_unsafe", + "tool_status": "success", + "exit_code": null, + "exit_code_note": "The Playwright MCP call is not a process and exposes success/error status rather than a numeric exit code.", + "transition": "์š”์•ฝ -> ๋ถ„๋ฅ˜ ๊ทœ์น™", + "sampling_interval_ms": 50, + "frame_count": 35, + "viewport": { + "width": 1280, + "height": 720 + }, + "browser": "HeadlessChrome/154.0.0.0", + "browser_architecture": "Linux x86_64", + "fixture_host_architecture": "aarch64", + "python": "3.13.5", + "streamlit": "1.58.0", + "uvicorn": "0.49.0" + }, + "checks": { + "document_identity_stable": true, + "sidebar_identity_stable": true, + "background_stable": true, + "zero_full_document_loads": true, + "loading_observed": true, + "previous_body_absent_while_loading": true, + "query_sibling_preserved": true, + "final_agreement": true + }, + "artifacts_sha256": { + "browser-observations.json": "d5dcd18f074b0f3e44e1b46eb9cf685f028b0f229957d35dc2330bedca5f0cbb", + "final-state.jpg": "5a9c94598776a9ddaf4bb1aed1c758f6d1c489c320f7a81ecb3acd0cb702760d" + }, + "validator": { + "command": "python3 -c \"import hashlib,json,pathlib; root=pathlib.Path('.'); m=json.loads((root/'docs/evidence/issue-9-dashboard-menu-transition/manifest.json').read_text()); assert all(hashlib.sha256((root/p).read_bytes()).hexdigest()==h for p,h in m['source']['files_sha256'].items()); evidence=root/'docs/evidence/issue-9-dashboard-menu-transition'; assert all(hashlib.sha256((evidence/p).read_bytes()).hexdigest()==h for p,h in m['artifacts_sha256'].items()); d=json.loads((evidence/'browser-observations.json').read_text()); assert all(d['checks'].values()) and not d['document_load_events']; assert any(f['loading'] and not f['previous_body_visible'] for f in d['frames']); print('PASS source_hashes=5 artifact_hashes=2 checks=8/8 frames=%d full_document_loads=0' % len(d['frames']))\"", + "exit_code": 0, + "output": "PASS source_hashes=5 artifact_hashes=2 checks=8/8 frames=35 full_document_loads=0" + }, + "limitations": [ + "The browser run used only a generated synthetic SQLite fixture; no operating ledger data was used.", + "A fixture-only external module wrapper delayed the rules renderer by two seconds to expose the loading interval; repository source was not modified for the delay.", + "A temporary HTTPS ingress adapter normalized trusted proxy scheme and Host metadata; repository authentication and same-origin checks remained active.", + "The ephemeral tunnel URL in browser-observations.json is expired and is retained only as provenance.", + "Playwright same-document URL events are recorded separately from full document load events." + ] +} diff --git a/docs/interface-contract.md b/docs/interface-contract.md index e47679a..21beda7 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -221,3 +221,41 @@ Set-Cookie: __Host-jarca_dashboard_auth=; Max-Age=0; Secure; HttpOnly; SameSite= - ์‹คํŒจ ๊ณ„์•ฝ: wrong credential, invalid configuration, cross-origin, malformed cookie tests - ๊ฒฝ๊ณ„ ๊ณ„์•ฝ: expiry, rotation, oversized token, same-host different-port origin tests - ๊ตฌํ˜„ยท์Šคํ‚ค๋งˆยท์˜ˆ์‹œ ์ •ํ•ฉ์„ฑ: `tests/test_dashboard_auth_server.py`, `tests/test_dashboard_authentication_app.py`, `tests/test_dashboard_persistent_authentication.py`, source/document redaction search + +## Dashboard ๋ฉ”๋‰ด ์ „ํ™˜ ๊ณ„์•ฝ โ€” REQ-002 + +### dashboard-menu-transition + +- ์ฑ…์ž„: authenticated Dashboard์—์„œ ๊ณตํ†ต shell/background๋ฅผ ์œ ์ง€ํ•˜๊ณ  ์„ ํƒ๋œ page body๋งŒ ๊ต์ฒดํ•œ๋‹ค. +- ์ข…๋ฅ˜ยทํ”„๋กœํ† ์ฝœ: Streamlit rerun, `st.radio` callback, query parameter +- ํ˜ธ์ถœ ๋Œ€์ƒยทoperation: `dashboard.app.main()` โ†’ `render_sidebar_nav()` โ†’ `render_dashboard_body()` +- ๋ฒ„์ „: 1.0 +- ํ˜ธ์ถœ ์ฃผ์ฒด: authenticated browser +- ์ธ์ฆ ๋ฐฉ์‹: ๊ธฐ์กด `gate` ๊ณ„์•ฝ ์„ ํ–‰; ๋ฉ”๋‰ด๋Š” ์ธ์ฆ์„ ์šฐํšŒํ•˜์ง€ ์•Š์Œ +- ํ•„์š” ๊ถŒํ•œ: valid Dashboard auth cookie + +#### ์ƒํƒœ์™€ ์ž…๋ ฅ + +์ •ํ™•ํ•œ menu options/order๋Š” `์›”์ •์‚ฐ`, `์š”์•ฝ`, `์ž์‚ฐ/๋งˆ๊ฐ`, `๊ฑฐ๋ž˜๋‚ด์—ญ`, `ํ™•์ธํ•„์š” ํ`, `๋ฏธ๋ถ„๋ฅ˜`, `๋ถ„๋ฅ˜ ๊ทœ์น™`, `์นดํ…Œ๊ณ ๋ฆฌ`, `๊ณ„์ขŒ`, `Import`์ด๋‹ค. selected value๋Š” `st.session_state["dashboard_page"]`์— ๋‘”๋‹ค. ์ง์ ‘ query์˜ `page`๊ฐ€ known option์ด๋ฉด ํ•ด๋‹น page๋ฅผ, absent/unknown์ด๋ฉด `์š”์•ฝ`์„ ์‚ฌ์šฉํ•œ๋‹ค. + +#### ์ถœ๋ ฅยท๋ถˆ๋ณ€์กฐ๊ฑด + +- callback์€ `st.query_params["page"]`๋งŒ ๊ฐฑ์‹ ํ•˜๊ณ  `month`, `account_id`, `filter` ๋“ฑ sibling key๋ฅผ ๋ณด์กดํ•œ๋‹ค. +- menu click์€ document/path navigation์ด ์•„๋‹ˆ๋ฉฐ stable `st.empty` body container ์•ˆ์—์„œ target renderer๋ฅผ ์‹คํ–‰ํ•œ๋‹ค. +- ๊ณตํ†ต sidebar, refresh control, CSS/background๋Š” ์œ ์ง€ํ•˜๊ณ  body๋งŒ target content๋กœ ๋Œ€์ฒดํ•œ๋‹ค. +- ๋А๋ฆฐ target์—๋Š” ` ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘โ€ฆ`์„ ํ‘œ์‹œํ•œ ๋’ค renderer ์™„๋ฃŒ ์‹œ placeholder๋ฅผ ์ œ๊ฑฐํ•œ๋‹ค. +- renderer ์˜ˆ์™ธ๋Š” body ์•ˆ์˜ `ํŽ˜์ด์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.`์™€ retry control๋กœ ๋Œ€์ฒดํ•˜๋ฉฐ ์˜ˆ์™ธ ์ƒ์„ธ๋ฅผ ๋…ธ์ถœํ•˜์ง€ ์•Š๋Š”๋‹ค. +- serialized callback์ด ์—ฌ๋Ÿฌ ๋ฒˆ ์‹คํ–‰๋˜๋ฉด ๋งˆ์ง€๋ง‰ selection์ด query์™€ body์˜ ์ตœ์ข… ์ƒํƒœ๊ฐ€ ๋œ๋‹ค. + +#### ์˜ค๋ฅ˜ + +| ์ฝ”๋“œ | ๋ถ„๋ฅ˜ | ๋ฐœ์ƒ ์กฐ๊ฑด | ์žฌ์‹œ๋„ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ | ํ˜ธ์ถœ์ž ๋Œ€์‘ | ์‹คํŒจ ์‹œ ์ƒํƒœ ๋ณ€๊ฒฝ | +|---|---|---|---|---|---| +| `page_fallback` | request | missing/unknown `page` | ๊ฐ€๋Šฅ | `์š”์•ฝ` ํ‘œ์‹œ | sibling query ์œ ์ง€ | +| `renderer_unavailable` | rendering | target renderer exception | ๊ฐ€๋Šฅ | generic error์˜ retry ํด๋ฆญ | previous body ์ œ๊ฑฐ, auth/shell ์œ ์ง€ | + +#### ๊ฒ€์ฆ ๊ฒฝ๊ณ„ + +AppTest๋Š” callback, query mapping, synthetic renderer output๊ณผ error/loading event๋ฅผ ๊ฒ€์ฆํ•˜์ง€๋งŒ ์‹ค์ œ browser์˜ document navigation ๋ถ€์žฌ, paint timing, focus/scroll, network request ๋˜๋Š” perceived shell continuity๋ฅผ ์ฆ๋ช…ํ•˜์ง€ ์•Š๋Š”๋‹ค. ๋”ฐ๋ผ์„œ `REQ-002.03-AC01`์€ authenticated real-browser evidence๊ฐ€ ํ•„์ˆ˜์ด๋ฉฐ AppTest๋Š” companion evidence์ผ ๋ฟ์ด๋‹ค. history/back-forward์™€ path URL์€ ๊ณ„์•ฝ์—์„œ ์ œ์™ธํ•œ๋‹ค. + +์ƒ์„ธ acceptance target์€ `docs/requirements/req-002_dashboard-menu-transition.md`์˜ `REQ-002.01-AC01`๋ถ€ํ„ฐ `REQ-002.09-AC01`๊นŒ์ง€๋ฅผ ๋”ฐ๋ฅธ๋‹ค. diff --git a/docs/project-structure.md b/docs/project-structure.md index 60ac861..54ae757 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -35,6 +35,8 @@ tests/ โ”œโ”€โ”€ test_dashboard_authentication.py โ”œโ”€โ”€ test_dashboard_persistent_authentication.py โ”œโ”€โ”€ test_dashboard_auth_server.py +โ”œโ”€โ”€ test_dashboard_menu_transition.py +โ”œโ”€โ”€ test_streamlit_browser_evidence.py โ””โ”€โ”€ test_dashboard_authentication_app.py ``` @@ -43,12 +45,23 @@ tests/ | `tests/test_dashboard_authentication.py` | test | credential compare, delay, redaction | pytest tests | test-only settings | source change paired with test | developer | | `tests/test_dashboard_persistent_authentication.py` | test | claims, TTL, tamper, rotation | pytest tests | no real secrets | source change paired with test | developer | | `tests/test_dashboard_auth_server.py` | test | HTTP routes and cookie scope | pytest tests | synthetic test credentials | source change paired with test | developer | +| `tests/test_dashboard_menu_transition.py` | test | REQ-002 menu/query/body transition and cache-clear regression | pytest/AppTest | synthetic fixture DB; no real secrets | source change paired with test and browser evidence where required | developer | +| `tests/test_streamlit_browser_evidence.py` | test | transition evidence manifest, redaction, TLS fixture and fail-closed validation | pytest | generated synthetic values only; no captured credential values | evidence-harness change paired with test | developer | | `tests/test_dashboard_authentication_app.py` | test | gate ordering and cleanup/UI | AppTest | fixture DB only | source change paired with test | developer | +### browser-evidence โ€” ์‹ค์ œ ์ „ํ™˜ ๊ด€์ฐฐ + +| ๊ฒฝ๋กœ | ์ข…๋ฅ˜ | ์ฑ…์ž„ | ๊ณต๊ฐœ ์ง„์ž…์  | ๋‚ด๋ถ€ ๊ตฌํ˜„ ๊ฒฝ๊ณ„ | ํŽธ์ง‘ ์ •์ฑ… | ์ƒ์„ฑ ์ฃผ์ฒด | +|---|---|---|---|---|---|---| +| `scripts/streamlit_browser_evidence.py` | verification tool | synthetic DB์™€ runtime ์ƒ์„ฑ ์ธ์ฆ ์„ค์ •์œผ๋กœ ์‹ค์ œ HTTPS Dashboard ์ „ํ™˜์˜ frame/DOM/document/shell ๊ด€์ฐฐ์„ ์ˆ˜์ง‘ํ•˜๊ณ  redacted manifest๋ฅผ ๊ฒ€์ฆ | `--mode transition` | ์šด์˜ DBยทcredential ์‚ฌ์šฉ ๊ธˆ์ง€; ์„ฑ๊ณต ์ฆ๊ฑฐ๋Š” browser/server teardown ๋’ค์—๋งŒ ํ™•์ • | source review and focused test required | developer | +| `docs/evidence/issue-9-dashboard-menu-transition/` | evidence artifact | exact source hash์— ๊ฒฐํ•ฉํ•œ authenticated browser frame, screenshot, runtime, validator ๊ฒฐ๊ณผ | `manifest.json` | synthetic fixture only; credential/token/cookie ๊ฐ’ ๊ธˆ์ง€ | immutable after verification except complete regeneration | verification runner | + ### deployment-docs โ€” ์šด์˜ ๋ฌธ์„œ ```text docs/requirements/req-001_dashboard-administrator-authentication.md +docs/requirements/req-002_dashboard-menu-transition.md +docs/evidence/issue-9-dashboard-menu-transition/ docs/interface-contract.md docs/runtime-flow.md README.md @@ -58,6 +71,7 @@ deploy/k8s/README.md | ๊ฒฝ๋กœ | ์ข…๋ฅ˜ | ์ฑ…์ž„ | ๊ณต๊ฐœ ์ง„์ž…์  | ๋‚ด๋ถ€ ๊ตฌํ˜„ ๊ฒฝ๊ณ„ | ํŽธ์ง‘ ์ •์ฑ… | ์ƒ์„ฑ ์ฃผ์ฒด | |---|---|---|---|---|---|---| | `docs/requirements/req-001_dashboard-administrator-authentication.md` | requirement | approved REQ-001 revision 2.0 | REQ-001 | canonical traceability | update in place | developer | +| `docs/requirements/req-002_dashboard-menu-transition.md` | requirement | approved REQ-002 revision 1.0 | REQ-002 | canonical traceability | update in place | developer | | `docs/interface-contract.md` | contract | HTTP/cookie/gate contracts | contract IDs | no implementation secrets | update with API changes | developer | | `docs/runtime-flow.md` | design | runtime states/transitions | flow IDs | no secret values | update with flow changes | developer | | `README.md` | operations | local/deployment constraints | operator commands | no secret values | operational review | developer | @@ -71,15 +85,16 @@ deploy/k8s/README.md | `dashboard.server` | `dashboard.app` | ํ—ˆ์šฉ | app remains protected by gate | `docs/runtime-flow.md` | | `dashboard.app` | `dashboard.authentication` | ํ—ˆ์šฉ | gate precedes DB/page dispatch | `docs/interface-contract.md` | | `dashboard.authentication` | `dashboard.page_views` | ๊ธˆ์ง€ | auth module never imports financial pages | `docs/project-structure.md` | +| `dashboard.app` | `dashboard.common` | ํ—ˆ์šฉ | menu/query/body transition helpers only after auth gate | `docs/interface-contract.md` | | `dashboard.auth_server` | `jarca_ledger_mcp` DB | ๊ธˆ์ง€ | login/cleanup/logout are DB-free | `docs/requirements/req-001_dashboard-administrator-authentication.md` | ## ๋ณ€๊ฒฝ ๊ทœ์น™ | ๊ตฌ๋ถ„ | ๊ทœ์น™ | |---|---| -| ์ƒˆ ๋ชจ๋“ˆยทํŒŒ์ผ ๋ฐฐ์น˜ | auth route/gate changes stay in `dashboard/`; no DB session module | +| ์ƒˆ ๋ชจ๋“ˆยทํŒŒ์ผ ๋ฐฐ์น˜ | auth route/gate changes stay in `dashboard/`; menu transition stays in existing `dashboard.app`/`dashboard.common`; no DB session module | | ์ด๋ฆ„ยทํŒจํ‚ค์ง€ | use existing `dashboard` package and snake_case modules | -| ์†Œ์Šคยทํ…Œ์ŠคํŠธ ๋Œ€์‘ | auth source behavior requires focused pytest/AppTest coverage | +| ์†Œ์Šคยทํ…Œ์ŠคํŠธ ๋Œ€์‘ | auth source behavior requires focused pytest/AppTest coverage; REQ-002 transition behavior requires focused pytest/AppTest targets and real-browser evidence where specified | | ์ƒ์„ฑ ์‚ฐ์ถœ๋ฌผ | evidence may be redacted; never commit credentials, secrets, tokens, or cookie values | ## ๊ฒ€์ฆ @@ -98,4 +113,4 @@ deploy/k8s/README.md ``` - ์ •์  ๋ถ„์„ ๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: exit 0 with no diagnostics -- ๋ฌธ์„œ์™€ ์‹ค์ œ ํŠธ๋ฆฌ์˜ ์ •ํ•ฉ์„ฑ: every listed source/test/document path exists; dependency table has no forbidden reverse edge +- ๋ฌธ์„œ์™€ ์‹ค์ œ ํŠธ๋ฆฌ์˜ ์ •ํ•ฉ์„ฑ: every listed source/test/document path exists; dependency table has no forbidden reverse edge; REQ-002 symbols resolve to `dashboard/app.py`, `dashboard/common.py`, and named tests diff --git a/docs/requirements/req-002_dashboard-menu-transition.md b/docs/requirements/req-002_dashboard-menu-transition.md new file mode 100644 index 0000000..af32080 --- /dev/null +++ b/docs/requirements/req-002_dashboard-menu-transition.md @@ -0,0 +1,258 @@ +--- +{ + "schema": "gitea-agent.requirement/v1", + "id": "REQ-002", + "title": "Dashboard ๋ฉ”๋‰ด ์ „ํ™˜ ์‹œ ๊ณตํ†ต ํ™”๋ฉด ์—ฐ์†์„ฑ", + "revision": "1.0", + "status": "approved", + "depends_on": ["REQ-001"], + "applies_to": ["dashboard"], + "language": "ko" +} +--- + +# REQ-002 โ€” Dashboard ๋ฉ”๋‰ด ์ „ํ™˜ ์‹œ ๊ณตํ†ต ํ™”๋ฉด ์—ฐ์†์„ฑ + +## 1. ๋ชฉ์  + +์ธ์ฆ๋œ Dashboard์—์„œ ๋ฉ”๋‰ด๋ฅผ ๋ฐ”๊ฟ€ ๋•Œ ๋ฌธ์„œ ์ „์ฒด๋ฅผ ์ด๋™์‹œํ‚ค์ง€ ์•Š๊ณ  ๊ณตํ†ต shell๊ณผ ๋ฐฐ๊ฒฝ์„ ์œ ์ง€ํ•˜๋ฉฐ body๋งŒ ๊ต์ฒดํ•œ๋‹ค. ํ˜„์žฌ ๋ฉ”๋‰ด ์„ ํƒ, URL ์ƒํƒœ, ๋А๋ฆฐ ๋ Œ๋”๋ง, ๋ Œ๋”๋ง ์‹คํŒจ์˜ ๋™์ž‘์„ ์˜ˆ์ธก ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋งŒ๋“ ๋‹ค. + +## 2. ๋ฐฐ๊ฒฝ + +Issue #9์˜ Dashboard ๋ฉ”๋‰ด ์ „ํ™˜ ๊ณ„์•ฝ์ด๋‹ค. REQ-001์˜ ์ธ์ฆ ๊ฒฝ๊ณ„, cookie, logout, page dispatch์™€ ๊ธฐ์กด ๊ธˆ์œต ํŽ˜์ด์ง€ ๊ณ„์•ฝ์„ ์œ ์ง€ํ•œ ์ฑ„ Streamlit ๋ฉ”๋‰ด ์ „ํ™˜์„ ์—ฐ์†์ ์ธ ๋‹จ์ผ ํ™”๋ฉด์œผ๋กœ ์ œ๊ณตํ•œ๋‹ค. + +## 3. ๋ฒ”์œ„ + +### ํฌํ•จ + +- Streamlit `st.radio` ๊ธฐ๋ฐ˜ ๋ฉ”๋‰ด์˜ ์ •ํ™•ํ•œ 10๊ฐœ label/order์™€ selected state +- ๋ฉ”๋‰ด ํด๋ฆญ ์‹œ document navigation ์—†์ด ๊ณตํ†ต shell/background๋ฅผ ์œ ์ง€ํ•˜๊ณ  body๋งŒ ๊ต์ฒด +- ๋А๋ฆฐ ๋Œ€์ƒ page ๋ Œ๋”๋ง ์ค‘ ๋Œ€์ƒ๋ณ„ loading ํ‘œ์‹œ +- renderer ์˜ˆ์™ธ์˜ local generic error์™€ retry +- ๋น ๋ฅธ ์—ฐ์† ์„ ํƒ์—์„œ latest serialized selection wins +- ์ง์ ‘ `?page=` ์ ‘๊ทผ, ๋ˆ„๋ฝ/unknown page์˜ `์š”์•ฝ` fallback, sibling query key ๋ณด์กด +- ๊ธฐ์กด auth gate, cookie, logout, finance page, cache-clear ํšŒ๊ท€ ๊ฒ€์ฆ + +### ์ œ์™ธ + +- browser history/back-forward ๋™์ž‘, history API ๊ณ„์•ฝ, path URL ์„ค๊ณ„ +- ์ธ์ฆ ๋ฐฉ์‹ยทcookie ์†์„ฑยทMCP transportยทSQLite schema ๋ณ€๊ฒฝ +- page renderer์˜ ๊ธˆ์œต ๊ณ„์‚ฐยทgateway ์˜๋ฏธ ๋ณ€๊ฒฝ +- ๋ณ„๋„ document route ๋˜๋Š” full-document navigation ๊ตฌํ˜„ + +## 4. ์šฉ์–ด์™€ ๊ธฐ์ค€ + +- **๊ณตํ†ต shell**: ์ธ์ฆ ํ›„ `dashboard/app.py`๊ฐ€ ์ œ๊ณตํ•˜๋Š” sidebar, ๋ฉ”๋‰ด, refresh control ๋ฐ ์•ฑ ๋ฐฐ๊ฒฝ/CSS. +- **body**: ์„ ํƒ๋œ page renderer๊ฐ€ ๊ณตํ†ต shell ์•ˆ์—์„œ ์ฑ„์šฐ๋Š” ์ฃผ ์ฝ˜ํ…์ธ  ์˜์—ญ. +- **latest serialized selection**: Streamlit rerun/์ฝœ๋ฐฑ ์ง๋ ฌํ™” ์ˆœ์„œ์—์„œ ๋งˆ์ง€๋ง‰์œผ๋กœ ๋ฐ˜์˜๋œ `dashboard_page` ์„ ํƒ. +- **AppTest**: Streamlit `streamlit.testing.v1.AppTest`์˜ synthetic runtime ๊ฒ€์ฆ. DOM, ์‹ค์ œ browser document navigation, ์‹ค์ œ paint/์ง€์—ฐ ์ฒด๊ฐ์€ ๊ฒ€์ฆํ•˜์ง€ ๋ชปํ•œ๋‹ค. +- **real-browser evidence**: ์‹ค์ œ browser์—์„œ authenticated session์œผ๋กœ ๋ฉ”๋‰ด๋ฅผ ํด๋ฆญํ•˜๊ณ  URL/document/body/shell์„ ๊ด€์ฐฐํ•ด ๋‚จ๊ธฐ๋Š” ์ฆ๊ฑฐ. AppTest๋ฅผ ๋Œ€์ฒดํ•˜์ง€ ์•Š์œผ๋ฉฐ AppTest๋งŒ์œผ๋กœ ์ถฉ์กฑํ•  ์ˆ˜ ์—†๋Š” browser-level ํ•ญ๋ชฉ์— ํ•„์ˆ˜๋‹ค. + +## 5. ์š”๊ตฌ์‚ฌํ•ญ + +### REQ-002.01 โ€” ๋ฉ”๋‰ด ์ •์ฒด์„ฑ + +๋ฉ”๋‰ด๋Š” ์ •ํ™•ํžˆ ๋‹ค์Œ ์ˆœ์„œ์™€ label์„ ์œ ์ง€ํ•œ๋‹ค: `์›”์ •์‚ฐ`, `์š”์•ฝ`, `์ž์‚ฐ/๋งˆ๊ฐ`, `๊ฑฐ๋ž˜๋‚ด์—ญ`, `ํ™•์ธํ•„์š” ํ`, `๋ฏธ๋ถ„๋ฅ˜`, `๋ถ„๋ฅ˜ ๊ทœ์น™`, `์นดํ…Œ๊ณ ๋ฆฌ`, `๊ณ„์ขŒ`, `Import`. selected state๋Š” ํ˜„์žฌ page์™€ ์ผ์น˜ํ•œ๋‹ค. + +### REQ-002.02 โ€” URL๊ณผ ์ง์ ‘ ์ ‘๊ทผ + +๋ฉ”๋‰ด ์„ ํƒ์€ query์˜ `page`๋งŒ ๋Œ€์ƒ label๋กœ ๊ฐฑ์‹ ํ•˜๊ณ  `month`, `account_id`, `filter` ๋“ฑ sibling query key๋ฅผ ๋ณด์กดํ•œ๋‹ค. ์ง์ ‘ `?page=`์€ ํ•ด๋‹น page๋ฅผ ๋ Œ๋”๋งํ•˜๊ณ , missing/unknown ๊ฐ’์€ `์š”์•ฝ`์œผ๋กœ fallbackํ•œ๋‹ค. + +### REQ-002.03 โ€” ์—ฐ์† ํ™”๋ฉด + +๋ฉ”๋‰ด ํด๋ฆญ์€ document navigation์„ ์ผ์œผํ‚ค์ง€ ์•Š๋Š”๋‹ค. ๊ณตํ†ต shell๊ณผ background๋Š” ์œ ์ง€ํ•˜๊ณ  body๋งŒ ์„ ํƒ๋œ renderer์˜ ๋‚ด์šฉ์œผ๋กœ ๊ต์ฒดํ•œ๋‹ค. history/back-forward์™€ path URL์€ ์ด ์š”๊ตฌ์‚ฌํ•ญ์˜ ํŒ์ • ๋ฒ”์œ„๊ฐ€ ์•„๋‹ˆ๋‹ค. + +### REQ-002.04 โ€” ์ „ํ™˜ ์ค‘ ํ‘œ์‹œ + +๋А๋ฆฐ renderer๊ฐ€ ์‹คํ–‰๋˜๋Š” ๋™์•ˆ target page label์„ ํฌํ•จํ•œ `๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘โ€ฆ` loading์„ ๋Œ€์ƒ body ์˜์—ญ์— ํ‘œ์‹œํ•˜๊ณ , renderer ์™„๋ฃŒ ํ›„ loading placeholder๋ฅผ ์ œ๊ฑฐํ•œ๋‹ค. + +### REQ-002.05 โ€” local failure์™€ retry + +target renderer ์˜ˆ์™ธ๋Š” ๊ณตํ†ต shell์„ ๋ฌด๋„ˆ๋œจ๋ฆฌ์ง€ ์•Š๊ณ  body ์˜์—ญ์„ generic error์™€ `๋‹ค์‹œ ์‹œ๋„` control๋กœ ๊ต์ฒดํ•œ๋‹ค. ์˜ˆ์™ธ ์ƒ์„ธยทsecret์€ UI์— ๋…ธ์ถœํ•˜์ง€ ์•Š๋Š”๋‹ค. + +### REQ-002.06 โ€” ์ตœ์‹  ์„ ํƒ ์šฐ์„  + +์—ฐ์† ๋ฉ”๋‰ด ์„ ํƒ์€ serialized callback ์ˆœ์„œ์˜ ๋งˆ์ง€๋ง‰ ์„ ํƒ์„ query์™€ body์— ๋ฐ˜์˜ํ•˜๋ฉฐ ์ด์ „ ์„ ํƒ์ด ๋งˆ์ง€๋ง‰ ์ƒํƒœ๋ฅผ ๋ฎ์–ด์“ฐ์ง€ ์•Š๋Š”๋‹ค. + +### REQ-002.07 โ€” ์ธ์ฆ ๊ฒฝ๊ณ„ ๋ณด์กด + +๋ชจ๋“  menu/page dispatch๋Š” REQ-001 gate์™€ cookie ๊ฒ€์ฆ ๋’ค์—๋งŒ ์‹คํ–‰๋œ๋‹ค. no-cookie/invalid-cookie๋Š” page body์— ๋„๋‹ฌํ•˜์ง€ ์•Š์œผ๋ฉฐ valid cookie๋Š” ๊ธฐ์กด Dashboard์— ์ ‘๊ทผํ•œ๋‹ค. + +### REQ-002.08 โ€” ๊ธˆ์œต page ํšŒ๊ท€ ์—†์Œ + +์š”์•ฝ, ์ž์‚ฐ/๋งˆ๊ฐ, ๋ถ„๋ฅ˜ ๊ทœ์น™, ํ™•์ธํ•„์š” ํ, ์›”์ •์‚ฐ์˜ ๊ธฐ์กด synthetic fixture DB ํ•ต์‹ฌ ๋‚ด์šฉ๊ณผ ๊ธฐ์กด page query ๊ณ„์•ฝ์„ ์œ ์ง€ํ•œ๋‹ค. + +### REQ-002.09 โ€” ์šด์˜ control ํšŒ๊ท€ ์—†์Œ + +refresh control์€ ๊ธฐ์กด `clear_cache` callable ๊ณ„์•ฝ์„ ์œ ์ง€ํ•˜๊ณ , ๋ฉ”๋‰ด ์ „ํ™˜์€ cache-clear ๋™์ž‘์„ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š๋Š”๋‹ค. + +## 6. ์ œ์•ฝ๊ณผ ๋ถˆ๋ณ€์กฐ๊ฑด + +- `dashboard/app.py`์˜ auth gate์™€ page dispatch๋Š” ์œ ์ง€ํ•˜๋ฉฐ ์ธ์ฆ ์ „์— DB path๋ฅผ resolveํ•˜์ง€ ์•Š๋Š”๋‹ค. +- ๋ฉ”๋‰ด ํด๋ฆญ์€ full document reload ๋˜๋Š” path navigation์„ ์ถ”๊ฐ€ํ•˜์ง€ ์•Š๋Š”๋‹ค. +- body ๊ต์ฒด๋Š” stable `st.empty` container ์•ˆ์—์„œ ์ˆ˜ํ–‰ํ•˜๋ฉฐ ๊ณตํ†ต shell/background๋ฅผ ์‚ญ์ œํ•˜์ง€ ์•Š๋Š”๋‹ค. +- loading/error/retry๋Š” target body์— ๊ตญ์†Œ์ ์œผ๋กœ ํ‘œ์‹œํ•˜๊ณ  generic error ์™ธ renderer exception ๋‚ด์šฉ์„ ๊ณต๊ฐœํ•˜์ง€ ์•Š๋Š”๋‹ค. +- query update๋Š” `page` key๋งŒ ๋ณ€๊ฒฝํ•˜๋ฉฐ sibling page-specific key๋ฅผ ์‚ญ์ œํ•˜๊ฑฐ๋‚˜ ์ •๊ทœํ™”ํ•˜์ง€ ์•Š๋Š”๋‹ค. +- REQ-001์˜ auth/cookie/logout, financial gateway/page, cache-clear, MCP ๋ฐ SQLite semantics๋Š” ๋ณ€๊ฒฝํ•˜์ง€ ์•Š๋Š”๋‹ค. + +## 7. ์ธ์ˆ˜ ๊ธฐ์ค€ + +๊ฐ ๊ธฐ์ค€์€ ํ•˜๋‚˜์˜ ๋ช…์‹œ๋œ test/evidence target์œผ๋กœ ํŒ์ •ํ•œ๋‹ค. AppTest๋Š” synthetic runtime ํŒ์ •์ด๋ฉฐ real-browser evidence๊ฐ€ ํ•„์š”ํ•œ ๊ธฐ์ค€์„ ๋Œ€์‹ ํ•˜์ง€ ์•Š๋Š”๋‹ค. + +### REQ-002.01-AC01 โ€” label/order/selected + +**ํŒ์ • ๊ธฐ์ค€** + +์ •ํ™•ํ•œ 10๊ฐœ label๊ณผ ์ˆœ์„œ๊ฐ€ radio options๋กœ ์ „๋‹ฌ๋˜๊ณ  `dashboard_page` selected state๊ฐ€ ์œ ์ง€๋œ๋‹ค. + +**๊ฒ€์ฆ** + +`tests/test_dashboard_menu_transition.py::test_dashboard_menu_labels_and_order_are_preserved` + +**๊ฒ€์ฆ ๋ถ„๋ฅ˜** + +- ์ˆ˜์ค€: unit +- ๋ฐฑ์—”๋“œ: pytest monkeypatch +- ์ฆ๊ฑฐ: radio options, key, label visibility assertion + +### REQ-002.02-AC01 โ€” query์™€ fallback + +**ํŒ์ • ๊ธฐ์ค€** + +callback์€ `page`๋งŒ ๊ฐฑ์‹ ํ•˜๊ณ  sibling key๋ฅผ ๋ณด์กดํ•˜๋ฉฐ, missing/unknown์€ `์š”์•ฝ`, known direct query๋Š” ์š”์ฒญ page๋ฅผ ์„ ํƒํ•œ๋‹ค. + +**๊ฒ€์ฆ** + +`tests/test_dashboard_menu_transition.py::test_sidebar_selection_updates_only_page_query_parameter`, `tests/test_dashboard_menu_transition.py::test_missing_or_unknown_page_falls_back_to_overview`, `tests/test_dashboard_menu_transition.py::test_direct_page_query_access_renders_requested_page` + +**๊ฒ€์ฆ ๋ถ„๋ฅ˜** + +- ์ˆ˜์ค€: unit/integration +- ๋ฐฑ์—”๋“œ: pytest monkeypatch +- ์ฆ๊ฑฐ: exact query mapping and selected renderer assertion + +### REQ-002.03-AC01 โ€” body-only transition + +**ํŒ์ • ๊ธฐ์ค€** + +์‹ค์ œ browser์—์„œ authenticated menu click ํ›„ document URL path์™€ full-document identity๊ฐ€ ๋ฐ”๋€Œ์ง€ ์•Š๊ณ  ๊ณตํ†ต sidebar/background๊ฐ€ ๋‚จ์œผ๋ฉฐ target body๋งŒ ๋ฐ”๋€๋‹ค. history/back-forward์™€ path URL์€ ํŒ์ •ํ•˜์ง€ ์•Š๋Š”๋‹ค. + +**๊ฒ€์ฆ** + +Required real-browser evidence: `docs/evidence/issue-9-dashboard-menu-transition/`์˜ authenticated `์š”์•ฝ` โ†’ `๋ถ„๋ฅ˜ ๊ทœ์น™` native click ์—ฐ์† frame, screenshot, DOM, URL, document/sidebar identity์™€ background ๊ด€์ฐฐ. `manifest.json`์€ ์ •ํ™•ํ•œ working-tree file hash, runtime viewportยทintervalยทํ™˜๊ฒฝยท์ œ์•ฝ, artifact hash ๋ฐ validator exit code๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. ์ด ์‹คํ–‰์€ local Chrome ๋ถ€์žฌ ๋•Œ๋ฌธ์— Playwright MCP browser runner๋กœ ์ˆ˜์ง‘ํ–ˆ์œผ๋ฉฐ, ์žฌํ˜„ ๊ฐ€๋Šฅํ•œ local fail-closed harness๋Š” `scripts/streamlit_browser_evidence.py --mode transition`๊ณผ `tests/test_streamlit_browser_evidence.py`๋กœ ์œ ์ง€ํ•œ๋‹ค. AppTest companion: `tests/test_dashboard_menu_transition.py::test_streamlit_menu_click_updates_body_and_preserves_page_query_siblings`. + +**๊ฒ€์ฆ ๋ถ„๋ฅ˜** + +- ์ˆ˜์ค€: browser evidence + integration companion +- ๋ฐฑ์—”๋“œ: real browser, Streamlit AppTest +- ํ•œ๊ณ„: AppTest๋Š” ์‹ค์ œ document navigation, browser paint, focus/scroll, network request, perceived continuity๋ฅผ ์ฆ๋ช…ํ•˜์ง€ ๋ชปํ•œ๋‹ค. + +### REQ-002.04-AC01 โ€” target loading + +**ํŒ์ • ๊ธฐ์ค€** + +slow renderer ๋™์•ˆ target label loading์ด ๋จผ์ € ํ‘œ์‹œ๋˜๊ณ  ์™„๋ฃŒ ํ›„ placeholder๊ฐ€ ์ œ๊ฑฐ๋œ๋‹ค. + +**๊ฒ€์ฆ** + +`tests/test_dashboard_menu_transition.py::test_slow_transition_clears_previous_body_and_shows_target_loading`; real-browser frames: `docs/evidence/issue-9-dashboard-menu-transition/browser-observations.json` + +**๊ฒ€์ฆ ๋ถ„๋ฅ˜** + +- ์ˆ˜์ค€: unit + browser evidence +- ๋ฐฑ์—”๋“œ: pytest fake Streamlit placeholder, authenticated Playwright browser +- ์ฆ๊ฑฐ: ordered event list์™€ 50 ms ์—ฐ์† frame์—์„œ `loading=true`, `previous_body_visible=false`, ์ตœ์ข… `loading=false` +- ํ•œ๊ณ„: browser evidence์˜ 2์ดˆ ์ง€์—ฐ์€ repository source๊ฐ€ ์•„๋‹Œ fixture-only wrapper๋กœ ์ฃผ์ž…ํ•œ๋‹ค. + +### REQ-002.05-AC01 โ€” local error/retry + +**ํŒ์ • ๊ธฐ์ค€** + +renderer ์˜ˆ์™ธ๊ฐ€ generic error/retry body๋กœ ๊ตญ์†Œ ๋Œ€์ฒด๋˜๊ณ  ์˜ˆ์™ธ ์ƒ์„ธ๊ฐ€ ๋…ธ์ถœ๋˜์ง€ ์•Š๋Š”๋‹ค. + +**๊ฒ€์ฆ** + +`tests/test_dashboard_menu_transition.py::test_body_error_is_local_and_retry_rerenders_target` + +**๊ฒ€์ฆ ๋ถ„๋ฅ˜** + +- ์ˆ˜์ค€: unit +- ๋ฐฑ์—”๋“œ: pytest monkeypatch +- ์ฆ๊ฑฐ: error text, retry event, secret-detail absence assertion + +### REQ-002.06-AC01 โ€” latest selection + +**ํŒ์ • ๊ธฐ์ค€** + +์—ฐ์† ์„ ํƒ `๊ฑฐ๋ž˜๋‚ด์—ญ` โ†’ `์ž์‚ฐ/๋งˆ๊ฐ` โ†’ `Import` ํ›„ query๊ฐ€ ๋งˆ์ง€๋ง‰ `Import`์ด๊ณ  sibling key๊ฐ€ ๋ณด์กด๋œ๋‹ค. + +**๊ฒ€์ฆ** + +`tests/test_dashboard_menu_transition.py::test_rapid_menu_clicks_commit_latest_selection_only` + +**๊ฒ€์ฆ ๋ถ„๋ฅ˜** + +- ์ˆ˜์ค€: unit +- ๋ฐฑ์—”๋“œ: pytest monkeypatch +- ์ฆ๊ฑฐ: final query mapping + +### REQ-002.07-AC01 โ€” auth regression + +**ํŒ์ • ๊ธฐ์ค€** + +valid cookie๊ฐ€ ๊ธฐ์กด Dashboard๋ฅผ ์—ด๊ณ  cookie๊ฐ€ session state์— ์ €์žฅ๋˜์ง€ ์•Š์œผ๋ฉฐ gate rejection์€ DB/page dispatch ์ „์— ์ข…๋ฃŒ๋œ๋‹ค. + +**๊ฒ€์ฆ** + +`tests/test_dashboard_authentication_app.py::test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state`, `tests/test_dashboard_authentication_app.py::test_main_does_not_resolve_database_or_dispatch_when_authentication_rejects` + +**๊ฒ€์ฆ ๋ถ„๋ฅ˜** + +- ์ˆ˜์ค€: integration regression +- ๋ฐฑ์—”๋“œ: Streamlit AppTest, synthetic fixture DB +- ์ฆ๊ฑฐ: authenticated body and pre-gate forbidden DB sentinel + +### REQ-002.08-AC01 โ€” finance page regression + +**ํŒ์ • ๊ธฐ์ค€** + +๊ธฐ์กด ํ•ต์‹ฌ ๊ธˆ์œต page์˜ expected text์™€ synthetic fixture DB ๊ฒฐ๊ณผ๊ฐ€ ์œ ์ง€๋œ๋‹ค. + +**๊ฒ€์ฆ** + +`tests/test_dashboard_app.py::test_streamlit_apptest_core_pages_use_synthetic_fixture_db` + +**๊ฒ€์ฆ ๋ถ„๋ฅ˜** + +- ์ˆ˜์ค€: regression integration +- ๋ฐฑ์—”๋“œ: Streamlit AppTest, synthetic SQLite +- ์ฆ๊ฑฐ: page expectation and metric assertions + +### REQ-002.09-AC01 โ€” cache-clear regression + +**ํŒ์ • ๊ธฐ์ค€** + +refresh control์ด callable `clear_cache` ๊ณ„์•ฝ์„ ์œ ์ง€ํ•œ๋‹ค. + +**๊ฒ€์ฆ** + +`tests/test_dashboard_menu_transition.py::test_refresh_control_preserves_cache_clear_contract` + +**๊ฒ€์ฆ ๋ถ„๋ฅ˜** + +- ์ˆ˜์ค€: regression unit +- ๋ฐฑ์—”๋“œ: pytest +- ์ฆ๊ฑฐ: callable/name assertion + +## 8. ์˜ค๋ฅ˜ ๋ฐ ์ถฉ๋Œ ๊ณ„์•ฝ + +์ธ์ฆ ์‹คํŒจ๋Š” ํ•ญ์ƒ REQ-001 gate๊ฐ€ ์šฐ์„ ํ•˜๋ฉฐ ๋ฉ”๋‰ดยทquery๋กœ ์šฐํšŒํ•  ์ˆ˜ ์—†๋‹ค. renderer ์‹คํŒจ๋Š” ํ•ด๋‹น body๋งŒ generic error/retry๋กœ ๋Œ€์ฒดํ•œ๋‹ค. ๋น ๋ฅธ ์„ ํƒ์€ Streamlit serialized callback ์ˆœ์„œ๋ฅผ ๋”ฐ๋ฅด๋ฉฐ ๋งˆ์ง€๋ง‰ serialized selection์ด ์ตœ์ข… ์ƒํƒœ๋‹ค. history/back-forward์™€ path URL์€ ์š”๊ตฌํ•˜์ง€ ์•Š๋Š”๋‹ค. + +## 9. ์ถ”์ ์„ฑ + +- ๊ทผ๊ฑฐ ์ด์Šˆ: `#9` +- ๊ตฌํ˜„ PR: ๋ฏธ์ • +- ๊ด€๋ จ ์š”๊ตฌ์‚ฌํ•ญ: `REQ-001` +- ๊ตฌํ˜„ ์ง„์ž…์ : `dashboard/app.py::main`, `dashboard/common.py::render_sidebar_nav`, `dashboard/common.py::validate_dashboard_page`, `dashboard/common.py::render_dashboard_body`, `dashboard/common.py::_sync_dashboard_page_query` +- ๊ฒ€์ฆ ํŒŒ์ผ: `tests/test_dashboard_menu_transition.py`, `tests/test_streamlit_browser_evidence.py`, `tests/test_dashboard_app.py`, `tests/test_dashboard_authentication_app.py` +- browser evidence ๋„๊ตฌ: `scripts/streamlit_browser_evidence.py --mode transition` diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 63f9049..292e5e9 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -112,3 +112,33 @@ ASGI startup๋ถ€ํ„ฐ login, token restoration, invalid-cookie cleanup, explicit lo - ๊ฒฝ๊ณ„ ํ๋ฆ„: TTL boundary, rotation, oversized token, same-host different-port origin - ๋™์‹œ์„ฑ ํ๋ฆ„: independent failed submissions and no application lock; ingress rate-limit configuration review - ์šด์˜ ์ง„์ž…์ ๊ณผ ๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: `uvicorn dashboard.server:app --host 0.0.0.0 --port 8501`; valid HTTPS reaches login or authenticated page, invalid cookie receives matching deletion before unauthenticated page + +## ๋ฉ”๋‰ด ์ „ํ™˜ โ€” REQ-002 + +์ธ์ฆ gate์™€ ๊ธฐ์กด `dashboard-01` page dispatch์˜ ์„ ํ–‰ ์ˆœ์„œ๋Š” ๋ณ€ํ•˜์ง€ ์•Š๋Š”๋‹ค. valid cookie๋ฅผ ํ†ต๊ณผํ•œ ๋’ค์—๋งŒ menu transition flow๊ฐ€ ์‹คํ–‰๋˜๋ฉฐ, auth ์‹คํŒจ๋Š” ๊ธฐ์กด cleanup/unauthenticated ํ๋ฆ„์œผ๋กœ ์ข…๋ฃŒ๋œ๋‹ค. + +### transition โ€” authenticated body replacement + +- ์‹œ์ž‘ ์ด๋ฒคํŠธ: authenticated browser selects a value in `st.radio(key="dashboard_page")` +- ์„ ํ–‰์กฐ๊ฑด: `gate-02` success; current page/query resolved +- ์‹คํ–‰ ๋ฐฉ์‹: Streamlit callback and rerun, no document/path navigation +- ํŠธ๋žœ์žญ์…˜ยท์˜์†์„ฑ ๊ฒฝ๊ณ„: no new persistence; existing financial SQLite rules unchanged + +| ๋‹จ๊ณ„ ID | ์‹คํ–‰ ์ฃผ์ฒด | ์‹œ์ž‘ ์กฐ๊ฑดยท์ž…๋ ฅ | ์ฒ˜๋ฆฌ | ์ฝ๊ธฐยท์“ฐ๊ธฐ ์ƒํƒœ | ์™ธ๋ถ€ ํ˜ธ์ถœ | ์„ฑ๊ณต ์‹œ ๋‹ค์Œ ๋‹จ๊ณ„ | ์‹คํŒจ ์‹œ ์ „์ด | +|---|---|---|---|---|---|---|---| +| `transition-01` | dashboard app/common | authenticated selected menu | `render_sidebar_nav` reads exact 10 options and selected state | session state `dashboard_page` | Streamlit radio callback | `transition-02` | `dashboard-fail` | +| `transition-02` | common callback | selected `dashboard_page` | write query `page` only; preserve sibling query keys | browser query state | Streamlit query params | `transition-03` | `transition-03` | +| `transition-03` | dashboard app | validated page | open stable `st.empty` body, show target loading, invoke target renderer | body placeholder only; existing DB reads/writes | page/gateway code | `transition-04` | `transition-error` | +| `transition-04` | dashboard app | renderer completed | remove target loading and leave target body; shell/background remain | body replacement only | none | `authenticated` | `dashboard-fail` | +| `transition-error` | dashboard app | renderer exception | clear partial body and show generic error/retry locally | body error state only | none | retry reruns target | `authenticated` with shell intact | + +### transition invariants + +- known direct `?page=` selects that page; missing/unknown selects `์š”์•ฝ`. +- serialized rapid selections commit the latest selection only. +- menu transition never runs before auth and never changes cookie/logout/DB schema/MCP semantics. +- history/back-forward and path URL behavior are explicitly outside this flow. + +### transition verification boundary + +`tests/test_dashboard_menu_transition.py` provides exact AppTest/unit evidence for labels, query preservation, fallback/direct access, loading, local error, latest selection, and cache-clear. `tests/test_dashboard_app.py::test_streamlit_apptest_core_pages_use_synthetic_fixture_db` and `tests/test_dashboard_authentication_app.py::test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state` cover finance/auth regressions. AppTest is synthetic and cannot establish actual browser document continuity, paint timing, focus/scroll, or network behavior; `scripts/streamlit_browser_evidence.py --mode transition`์˜ authenticated HTTPS frame/DOM/document/shell evidence๊ฐ€ REQ-002.03-AC01์— ํ•„์ˆ˜๋‹ค. `tests/test_streamlit_browser_evidence.py`๋Š” ์ด manifest๊ฐ€ ์‹ค์ œ teardown ์ดํ›„์—๋งŒ ์„ฑ๊ณตํ•˜๊ณ  document navigation, shell identity drift, ๋ˆ„๋ฝ ๊ด€์ฐฐ ๋˜๋Š” secret ํฌํ•จ ์‹œ fail-closedํ•˜๋Š”์ง€ ๊ฒ€์ฆํ•œ๋‹ค. diff --git a/scripts/streamlit_browser_evidence.py b/scripts/streamlit_browser_evidence.py index 1ca9eed..b3ae62a 100755 --- a/scripts/streamlit_browser_evidence.py +++ b/scripts/streamlit_browser_evidence.py @@ -14,10 +14,15 @@ import asyncio import base64 import json import os +import platform import shutil import signal import subprocess +import sys import time +import hashlib +import secrets +import ssl import urllib.parse import urllib.request from datetime import datetime @@ -44,6 +49,13 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--output-dir", default="", help="Evidence output directory. Defaults under docs/batches/BAT-004/evidence/run-.") parser.add_argument("--page", action="append", dest="pages", help="Page to capture. Repeatable. Defaults to core E2E evidence pages.") parser.add_argument("--keep-server", action="store_true", help="Leave Streamlit running after capture for manual inspection.") + parser.add_argument("--mode", choices=("capture", "transition"), default="capture", help="Capture mode") + parser.add_argument("--cert-file", default="", help="TLS certificate for transition mode") + parser.add_argument("--key-file", default="", help="TLS private key for transition mode") + parser.add_argument("--from-page", default="์š”์•ฝ") + parser.add_argument("--to-page", default="๋ถ„๋ฅ˜ ๊ทœ์น™") + parser.add_argument("--interval-ms", type=int, default=100) + parser.add_argument("--frames", type=int, default=8) return parser.parse_args() @@ -145,12 +157,13 @@ def seed_fixture_db(db_path: Path) -> dict[str, Any]: return {"account_id": account_id, "transaction_ids": transactions, "db_path": str(db_path)} -def wait_for_http(url: str, timeout_seconds: int = 45) -> None: +def wait_for_http(url: str, timeout_seconds: int = 45, ssl_context: ssl.SSLContext | None = None) -> None: + """Wait for HTTP readiness, using verified TLS unless explicitly overridden.""" deadline = time.time() + timeout_seconds last_error: str | None = None while time.time() < deadline: try: - with urllib.request.urlopen(url, timeout=2) as response: + with urllib.request.urlopen(url, timeout=2, context=ssl_context) as response: if response.status == 200: return last_error = f"HTTP {response.status}" @@ -195,6 +208,7 @@ class CdpClient: def __init__(self, ws: websockets.ClientConnection) -> None: self.ws = ws self.next_id = 1 + self.events: list[dict[str, Any]] = [] async def call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: command_id = self.next_id @@ -207,6 +221,8 @@ class CdpClient: if "error" in payload: raise RuntimeError(f"CDP {method} failed: {payload['error']}") return payload.get("result", {}) + if payload.get("method"): + self.events.append(payload) async def cdp_capture(ws_url: str, url: str, expected_text: str, screenshot: Path, snapshot: Path) -> dict[str, Any]: @@ -295,10 +311,196 @@ def capture_page(chrome: str, base_url: str, page: str, output_dir: Path, devtoo } +def redact_secrets(value: Any, secrets_to_redact: tuple[str, ...] = ()) -> Any: + """Return JSON-safe evidence with credential-like values removed.""" + if isinstance(value, dict): + return {key: redact_secrets(item, secrets_to_redact) for key, item in value.items()} + if isinstance(value, list): + return [redact_secrets(item, secrets_to_redact) for item in value] + if isinstance(value, str): + result = value + for secret in secrets_to_redact: + if secret: + result = result.replace(secret, "[REDACTED]") + return result + return value + + +def validate_transition_manifest(manifest: dict[str, Any], secrets_to_redact: tuple[str, ...] = ()) -> None: + """Fail closed unless transition evidence contains every required observation.""" + serialized = json.dumps(manifest, ensure_ascii=False) + if any(secret and secret in serialized for secret in secrets_to_redact): + raise ValueError("secret value present in transition manifest") + if manifest.get("success") is not True: + raise ValueError("transition manifest is not successful") + observations = manifest.get("observations") or {} + frames = observations.get("frames") + if not isinstance(frames, list) or len(frames) < 2: + raise ValueError("transition observations require at least two frames") + document_events = observations.get("document_navigation_events") + if not isinstance(document_events, list) or document_events: + raise ValueError("transition observations require zero document navigation events") + shell = observations.get("stable_shell") or {} + if not all(isinstance(shell.get(name), dict) for name in ("document_marker", "sidebar_marker", "background")): + raise ValueError("transition observations require pre/post stable shell markers") + if any(values.get("pre") != values.get("post") for values in (shell["document_marker"], shell["sidebar_marker"], shell["background"])): + raise ValueError("transition observations require stable shell identity and background") + if observations.get("prior_body_removed") is not True: + raise ValueError("transition observations require prior body removal") + loading_frames = [frame for frame in frames if frame.get("loading") is True] + if observations.get("loading_observed") is not True or not loading_frames: + raise ValueError("transition observations require observed loading state") + if any(frame.get("prior_body_visible") is not False for frame in loading_frames): + raise ValueError("transition observations require prior body absence while loading") + if not observations.get("target_page"): + raise ValueError("transition observations require target page") + if observations.get("final_agreement") is not True: + raise ValueError("transition observations require target body agreement") + runtime = manifest.get("runtime") or {} + if not runtime.get("viewport") or not isinstance(runtime.get("interval_ms"), int): + raise ValueError("transition manifest requires viewport and interval") + command = manifest.get("command") or {} + if command.get("exit_code") != 0 or command.get("teardown") != "complete": + raise ValueError("transition manifest requires successful command teardown") + source_revision = manifest.get("source_revision") + if not isinstance(source_revision, str) or len(source_revision) != 40: + raise ValueError("transition manifest requires full source revision") + source_files = manifest.get("source_files") + if not isinstance(source_files, dict) or not source_files or any( + not isinstance(path, str) + or not isinstance(digest, str) + or len(digest) != 64 + for path, digest in source_files.items() + ): + raise ValueError("transition manifest requires source files and SHA-256 digests") + source_paths = tuple(source_files) + if manifest.get("source_revision_kind") != "git-head-base": + raise ValueError("transition manifest requires git-head-base revision kind") + if source_revision != current_git_revision(): + raise ValueError("transition manifest source revision does not match current Git HEAD") + if source_files != source_file_digests(source_paths): + raise ValueError("transition manifest source file digests do not match current files") + if manifest.get("source_worktree_dirty") != git_worktree_dirty(source_paths): + raise ValueError("transition manifest working-tree state does not match current files") + if not isinstance(manifest.get("evidence_command"), list) or not manifest["evidence_command"]: + raise ValueError("transition manifest requires evidence command") + if not runtime.get("host_architecture") or not runtime.get("browser_user_agent"): + raise ValueError("transition manifest requires runtime identity") + + +def current_git_revision() -> str: + return subprocess.run( + ["git", "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def git_worktree_dirty(paths: tuple[str, ...]) -> bool: + result = subprocess.run( + ["git", "status", "--porcelain", "--", *paths], + check=True, + capture_output=True, + text=True, + ) + return bool(result.stdout.strip()) + + +def source_file_digests(paths: tuple[str, ...]) -> dict[str, str]: + for path in paths: + candidate = Path(path) + if candidate.is_absolute() or ".." in candidate.parts: + raise ValueError("source file paths must be repository-relative") + return {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() for path in paths} + + +async def cdp_transition(ws_url: str, base_url: str, from_page: str, to_page: str, + screenshot: Path, snapshot: Path, interval_ms: int, frame_count: int, + admin_id: str, admin_password: str) -> dict[str, Any]: + async with websockets.connect(ws_url, max_size=16 * 1024 * 1024) as ws: + cdp = CdpClient(ws) + await cdp.call("Page.enable") + await cdp.call("Runtime.enable") + await cdp.call("Emulation.setDeviceMetricsOverride", {"width": 1440, "height": 1400, "deviceScaleFactor": 1, "mobile": False}) + await cdp.call("Page.navigate", {"url": base_url}) + await asyncio.sleep(1) + login_expression = """(async () => { + const body = new URLSearchParams({admin_id: %s, admin_password: %s}); + const response = await fetch('/api/auth/login', {method:'POST', credentials:'include', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body}); + return {status: response.status, location: response.url}; + })()""" % (json.dumps(admin_id), json.dumps(admin_password)) + await cdp.call("Runtime.evaluate", {"expression": login_expression, "awaitPromise": True, "returnByValue": True}) + await cdp.call("Page.navigate", {"url": f"{base_url}/?page={urllib.parse.quote(from_page)}"}) + deadline = time.time() + 45 + while time.time() < deadline: + check = await cdp.call("Runtime.evaluate", {"expression": "document.body ? document.body.innerText : ''", "returnByValue": True}) + if from_page in (check.get("result", {}).get("value", "") or ""): + break + await asyncio.sleep(.5) + install = """(() => { + window.__evidenceEvents=[]; + window.__evidenceDocumentMarker ||= crypto.randomUUID(); + window.__evidencePriorBodyText = (document.querySelector('[data-testid=stMain]') || document.body)?.innerText || ''; + const sidebar=document.querySelector('[data-testid=stSidebar]'); + if (sidebar) sidebar.dataset.evidenceMarker ||= crypto.randomUUID(); + for (const name of ['pushState','replaceState']) { + const old=history[name]; + history[name]=function(){window.__evidenceEvents.push({type:name,url:String(arguments[2]||location.href)}); return old.apply(this,arguments)}; + } + })()""" + await cdp.call("Runtime.evaluate", {"expression": install}) + cdp.events.clear() + observations: list[dict[str, Any]] = [] + click = """(() => { const labels=[...document.querySelectorAll('label')]; const target=labels.find(x=>x.innerText.trim()===%s); if(target){target.click(); return true;} const input=[...document.querySelectorAll('input')].find(x=>x.value===%s); if(input){input.click(); return true;} return false; })()""" % (json.dumps(to_page), json.dumps(to_page)) + deadline = time.time() + 45 + index = 0 + while time.time() < deadline and (index < frame_count or not observations or observations[-1].get("selected_menu") != to_page): + expression = """(() => { const body=document.body; const text=body?.innerText||''; const main=document.querySelector('[data-testid=stMain]')||body; const mainText=main.innerText||''; const sidebar=document.querySelector('[data-testid=stSidebar]'); const selectedInput=document.querySelector('[data-testid=stRadio] input:checked'); const selected=selectedInput?.closest('label')?.innerText?.trim() || document.querySelector('[aria-current=page]')?.textContent?.trim() || ''; const prior=window.__evidencePriorBodyText||''; return {at_ms:%d, selected_menu:selected, body_text:mainText, prior_body_visible:Boolean(prior)&&mainText.includes(prior), document_marker:window.__evidenceDocumentMarker||'', sidebar_marker:sidebar?.dataset.evidenceMarker||'', background:getComputedStyle(document.body).backgroundColor, loading:/๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘/.test(text)}; })()""" % (index * interval_ms) + result = await cdp.call("Runtime.evaluate", {"expression": expression, "returnByValue": True}) + frame = result.get("result", {}).get("value", {}) + frame["body_text_hash"] = hashlib.sha256(frame.get("body_text", "").encode()).hexdigest() + frame.pop("body_text", None) + observations.append(frame) + if index == 0: + await cdp.call("Runtime.evaluate", {"expression": click, "returnByValue": True}) + index += 1 + await asyncio.sleep(interval_ms / 1000) + final = await cdp.call("Runtime.evaluate", {"expression": "(() => { const main=document.querySelector('[data-testid=stMain]')||document.body; const sidebar=document.querySelector('[data-testid=stSidebar]'); const selectedInput=document.querySelector('[data-testid=stRadio] input:checked'); return {url:location.href, body:main.innerText||'', selected:selectedInput?.closest('label')?.innerText?.trim() || document.querySelector('[aria-current=page]')?.textContent?.trim() || '', document_marker:window.__evidenceDocumentMarker||'', sidebar_marker:sidebar?.dataset.evidenceMarker||'', background:getComputedStyle(document.body).backgroundColor, history_events:window.__evidenceEvents||[], browser_user_agent:navigator.userAgent}; })()", "returnByValue": True}) + data = final.get("result", {}).get("value", {}) + html = await cdp.call("Runtime.evaluate", {"expression": "document.documentElement.outerHTML", "returnByValue": True}) + html_text = html.get("result", {}).get("value", "") + snapshot.write_text(redact_secrets(html_text, (admin_id, admin_password)), encoding="utf-8") + image = await cdp.call("Page.captureScreenshot", {"format":"png", "fromSurface":True, "captureBeyondViewport":True}) + screenshot.write_bytes(base64.b64decode(image["data"])) + document_events = [{"type": event.get("method"), "params": event.get("params", {})} for event in cdp.events if event.get("method") in ("Page.frameNavigated", "Page.loadEventFired")] + pre = observations[0] if observations else {} + loading_frames = [frame for frame in observations if frame.get("loading")] + return {"frames": observations, "document_navigation_events": document_events, "history_events": data.get("history_events", []), "stable_shell": {"document_marker": {"pre": pre.get("document_marker"), "post": data.get("document_marker")}, "sidebar_marker": {"pre": pre.get("sidebar_marker"), "post": data.get("sidebar_marker")}, "background": {"pre": pre.get("background"), "post": data.get("background")}}, "loading_observed": bool(loading_frames), "prior_body_removed": bool(loading_frames) and all(frame.get("prior_body_visible") is False for frame in loading_frames), "target_page": to_page, "final_agreement": data.get("selected") == to_page and to_page in data.get("body", ""), "final_url": data.get("url", ""), "browser_user_agent": data.get("browser_user_agent", "")} + + +def transition_capture(chrome: str, base_url: str, from_page: str, to_page: str, output_dir: Path, devtools_port: int, + interval_ms: int, frame_count: int, + admin_id: str, admin_password: str) -> dict[str, Any]: + profile_dir = output_dir / "chrome-profile-transition" + screenshot, snapshot = output_dir / "transition.png", output_dir / "transition.dom.html" + chrome_cmd = [chrome, "--headless=new", "--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors", f"--remote-debugging-port={devtools_port}", f"--user-data-dir={profile_dir}", "--window-size=1440,1400", "about:blank"] + proc = subprocess.Popen(chrome_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + try: + ws_url = wait_for_devtools(devtools_port) + return asyncio.run(cdp_transition(ws_url, base_url, from_page, to_page, screenshot, snapshot, interval_ms, frame_count, admin_id, admin_password)) + finally: + if proc.poll() is None: + proc.terminate() + try: proc.wait(timeout=8) + except subprocess.TimeoutExpired: proc.kill(); proc.wait(timeout=5) + remove_tree_with_retries(profile_dir) + + def main() -> int: args = parse_args() chrome = require_executable(args.chrome, "Chrome") - streamlit = require_executable(args.streamlit, "Streamlit") + streamlit = None if args.mode == "transition" else require_executable(args.streamlit, "Streamlit") run_id = datetime.now().strftime("run-%Y%m%d-%H%M%S") output_dir = Path(args.output_dir) if args.output_dir else DEFAULT_OUTPUT_ROOT / run_id output_dir.mkdir(parents=True, exist_ok=True) @@ -308,56 +510,105 @@ def main() -> int: env = os.environ.copy() env["JARCA_LEDGER_DB_PATH"] = str(db_path.resolve()) env["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false" - base_url = f"http://{args.host}:{args.port}" - server_cmd = [ - streamlit, - "run", - "dashboard/app.py", - "--server.address", - args.host, - "--server.port", - str(args.port), - "--server.headless", - "true", - "--browser.gatherUsageStats", - "false", - ] + transition_secrets: tuple[str, ...] = () + if args.mode == "transition": + if not args.cert_file or not args.key_file: + raise SystemExit("transition mode requires --cert-file and --key-file") + env["JARCA_DASHBOARD_ADMIN_ID"] = secrets.token_urlsafe(18) + env["JARCA_DASHBOARD_ADMIN_PASSWORD"] = secrets.token_urlsafe(32) + env["JARCA_DASHBOARD_SIGNING_SECRET"] = secrets.token_hex(32) + transition_secrets = (env["JARCA_DASHBOARD_ADMIN_ID"], env["JARCA_DASHBOARD_ADMIN_PASSWORD"], env["JARCA_DASHBOARD_SIGNING_SECRET"]) + scheme = "https" if args.mode == "transition" else "http" + base_url = f"{scheme}://{args.host}:{args.port}" + if args.mode == "transition": + server_cmd = [sys.executable, "-m", "uvicorn", "dashboard.server:app", "--host", args.host, "--port", str(args.port), "--ssl-certfile", args.cert_file, "--ssl-keyfile", args.key_file] + else: + server_cmd = [ + streamlit, "run", "dashboard/app.py", "--server.address", args.host, + "--server.port", str(args.port), "--server.headless", "true", + "--browser.gatherUsageStats", "false", + ] server = subprocess.Popen(server_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, start_new_session=True) manifest: dict[str, Any] = { "success": False, + "mode": args.mode, "base_url": base_url, "output_dir": str(output_dir.resolve()), - "fixture": fixture, + "fixture": {"db_path": fixture["db_path"]}, "chrome": chrome, "streamlit": streamlit, "pages": [], "server_command": server_cmd, } + error: str | None = None try: - wait_for_http(base_url) - for index, page in enumerate(args.pages or DEFAULT_PAGES): - manifest["pages"].append(capture_page(chrome, base_url, page, output_dir, devtools_port=19222 + index)) - manifest["success"] = True - print(json.dumps(manifest, ensure_ascii=False, indent=2)) - return 0 + wait_for_http(base_url, ssl_context=ssl._create_unverified_context() if args.mode == "transition" else None) + if args.mode == "transition": + observations = transition_capture(chrome, base_url, args.from_page, args.to_page, output_dir, 19222, args.interval_ms, args.frames, env["JARCA_DASHBOARD_ADMIN_ID"], env["JARCA_DASHBOARD_ADMIN_PASSWORD"]) + source_paths = ( + "dashboard/app.py", + "dashboard/common.py", + "scripts/streamlit_browser_evidence.py", + ) + manifest.update( + { + "observations": observations, + "runtime": { + "viewport": {"width": 1440, "height": 1400}, + "interval_ms": args.interval_ms, + "frame_count": args.frames, + "host_architecture": platform.machine(), + "python_version": platform.python_version(), + "browser_user_agent": observations.get("browser_user_agent", ""), + }, + "source_revision": current_git_revision(), + "source_revision_kind": "git-head-base", + "source_worktree_dirty": git_worktree_dirty(source_paths), + "source_files": source_file_digests(source_paths), + "evidence_command": ["scripts/streamlit_browser_evidence.py", *sys.argv[1:]], + "limitations": ["TLS uses caller-provided local certificate"], + } + ) + else: + for index, page in enumerate(args.pages or DEFAULT_PAGES): + manifest["pages"].append(capture_page(chrome, base_url, page, output_dir, devtools_port=19222 + index)) + except Exception as exc: # noqa: BLE001 - failure is recorded in redacted evidence + error = str(exc) finally: - log_tail = "" - if server.stdout is not None: - try: - time.sleep(0.5) - if server.poll() is not None: - log_tail = server.stdout.read()[-4000:] - except Exception: - log_tail = "" - manifest["server_log_tail"] = log_tail - (output_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") if not args.keep_server and server.poll() is None: os.killpg(server.pid, signal.SIGTERM) try: server.wait(timeout=10) except subprocess.TimeoutExpired: os.killpg(server.pid, signal.SIGKILL) + server.wait(timeout=5) + returncode = server.poll() + teardown = "complete" if not args.keep_server and returncode is not None else "incomplete" + effective_exit = 0 if returncode in (0, -signal.SIGTERM) else returncode + manifest["server_log_tail"] = "" + manifest["command"] = {"exit_code": effective_exit, "teardown": teardown} + if error: + manifest["error"] = error + if args.mode == "transition" and error is None and teardown == "complete" and effective_exit == 0: + try: + manifest["success"] = True + validate_transition_manifest(manifest, transition_secrets) + except Exception as exc: # noqa: BLE001 - preserve failed manifest + manifest["success"] = False + manifest["error"] = str(exc) + elif args.mode == "capture" and error is None and ( + (args.keep_server and returncode is None) + or (teardown == "complete" and effective_exit == 0) + ): + manifest["success"] = True + safe_manifest = redact_secrets(manifest, transition_secrets) + manifest_path = output_dir / "manifest.json" + temporary_path = output_dir / ".manifest.json.tmp" + temporary_path.write_text(json.dumps(safe_manifest, ensure_ascii=False, indent=2), encoding="utf-8") + temporary_path.replace(manifest_path) + print(json.dumps(safe_manifest, ensure_ascii=False, indent=2)) + return 0 if manifest.get("success") is True else 1 if __name__ == "__main__": diff --git a/tests/test_dashboard_menu_transition.py b/tests/test_dashboard_menu_transition.py new file mode 100644 index 0000000..9fb3d79 --- /dev/null +++ b/tests/test_dashboard_menu_transition.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from pathlib import Path +import sys + +import pytest +from streamlit.testing.v1 import AppTest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from dashboard import app, authentication, common +from scripts.ci_fixture_db import create_ci_fixture_db + + +PAGES = ["์›”์ •์‚ฐ", "์š”์•ฝ", "์ž์‚ฐ/๋งˆ๊ฐ", "๊ฑฐ๋ž˜๋‚ด์—ญ", "ํ™•์ธํ•„์š” ํ", "๋ฏธ๋ถ„๋ฅ˜", "๋ถ„๋ฅ˜ ๊ทœ์น™", "์นดํ…Œ๊ณ ๋ฆฌ", "๊ณ„์ขŒ", "Import"] + + +def test_dashboard_menu_labels_and_order_are_preserved(monkeypatch): + calls = {} + + def radio(label, options, **kwargs): + calls.update(label=label, options=list(options), kwargs=kwargs) + return "์š”์•ฝ" + + monkeypatch.setattr(common.st, "radio", radio) + assert common.render_sidebar_nav(PAGES, "์š”์•ฝ") == "์š”์•ฝ" + assert calls["options"] == PAGES + assert calls["kwargs"]["key"] == "dashboard_page" + assert calls["kwargs"]["label_visibility"] == "collapsed" + + +def test_sidebar_selection_updates_only_page_query_parameter(monkeypatch): + query = {"page": "์š”์•ฝ", "month": "2026-06", "account_id": "checking"} + state = {"dashboard_page": "๊ฑฐ๋ž˜๋‚ด์—ญ"} + callback = {} + + monkeypatch.setattr(common.st, "query_params", query) + monkeypatch.setattr(common.st, "session_state", state) + monkeypatch.setattr(common.st, "radio", lambda *args, **kwargs: callback.setdefault("fn", kwargs["on_change"]) or state["dashboard_page"]) + common.render_sidebar_nav(PAGES, "์š”์•ฝ") + callback["fn"]() + + assert query == {"page": "๊ฑฐ๋ž˜๋‚ด์—ญ", "month": "2026-06", "account_id": "checking"} + + +def test_missing_or_unknown_page_falls_back_to_overview(): + assert common.validate_dashboard_page(None, PAGES) == "์š”์•ฝ" + assert common.validate_dashboard_page("not-a-page", PAGES) == "์š”์•ฝ" + assert common.validate_dashboard_page("๊ฑฐ๋ž˜๋‚ด์—ญ", PAGES) == "๊ฑฐ๋ž˜๋‚ด์—ญ" + + +def test_direct_page_query_access_renders_requested_page(monkeypatch): + selected = {} + monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True) + monkeypatch.setattr(app, "get_db_path", lambda: "fixture.sqlite3") + monkeypatch.setattr(app, "inject_app_css", lambda: None) + monkeypatch.setattr(app.st, "query_params", {"page": "๊ณ„์ขŒ"}) + monkeypatch.setattr(app.st, "session_state", {"dashboard_page": "์š”์•ฝ"}) + + def capture_sidebar_page(pages, page): + selected["sidebar_page"] = page + selected["session_page"] = app.st.session_state["dashboard_page"] + return page + + monkeypatch.setattr(app, "render_sidebar_nav", capture_sidebar_page) + monkeypatch.setattr(app.st, "sidebar", pytest.importorskip("contextlib").nullcontext()) + monkeypatch.setattr(app.st, "button", lambda *args, **kwargs: False) + monkeypatch.setattr(app, "page_accounts", lambda db: selected.setdefault("db", db)) + for name in PAGES: + if name != "๊ณ„์ขŒ": + monkeypatch.setattr(app, {"์›”์ •์‚ฐ": "page_monthly_settlement", "์š”์•ฝ": "page_overview", "์ž์‚ฐ/๋งˆ๊ฐ": "page_assets", "๊ฑฐ๋ž˜๋‚ด์—ญ": "page_transactions", "ํ™•์ธํ•„์š” ํ": "page_cross_transfer_review", "๋ฏธ๋ถ„๋ฅ˜": "page_uncategorized", "๋ถ„๋ฅ˜ ๊ทœ์น™": "page_rules", "์นดํ…Œ๊ณ ๋ฆฌ": "page_categories", "Import": "page_imports"}[name], lambda db: None) + monkeypatch.setattr(app, "render_dashboard_body", lambda page, renderer: renderer()) + + app.main() + assert selected == { + "sidebar_page": "๊ณ„์ขŒ", + "session_page": "๊ณ„์ขŒ", + "db": "fixture.sqlite3", + } + + +def test_streamlit_menu_click_updates_body_and_preserves_page_query_siblings(tmp_path, monkeypatch): + fixture = tmp_path / "fixture.sqlite3" + create_ci_fixture_db(fixture) + monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture)) + monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin") + monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "test-password") + monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "s" * 64) + settings = authentication.load_auth_settings() + assert settings is not None + token = authentication.issue_persistent_token(settings, remember_me=False).value + monkeypatch.setattr(authentication, "_request_cookie", lambda: token) + + app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py" + at = AppTest.from_file(str(app_path), default_timeout=10) + at.query_params["page"] = "์š”์•ฝ" + at.query_params["month"] = "2026-06" + at.run() + + at.radio(key="dashboard_page").set_value("๋ถ„๋ฅ˜ ๊ทœ์น™").run() + + assert not at.exception + assert at.query_params["page"] == ["๋ถ„๋ฅ˜ ๊ทœ์น™"] + assert at.query_params["month"] == ["2026-06"] + body_text = "\n".join(str(item.value) for item in list(at.text) + list(at.markdown) + list(at.caption)) + assert "ํ˜„์žฌ ๋ถ„๋ฅ˜ ๊ทœ์น™" in body_text + + +def test_slow_transition_clears_previous_body_and_shows_target_loading(monkeypatch): + events = [] + monkeypatch.setattr(common.st, "empty", lambda: _FakePlaceholder(events)) + monkeypatch.setattr(common.st, "container", lambda **kwargs: _FakeKeyedContainer(events, kwargs["key"])) + monkeypatch.setattr(common.st, "info", lambda message: events.append(("info", message))) + common.render_dashboard_body("๊ฑฐ๋ž˜๋‚ด์—ญ", lambda: events.append(("render", "๊ฑฐ๋ž˜๋‚ด์—ญ"))) + assert events[0] == ("empty", "placeholder") + assert ("open", "dashboard_body") in events + assert ("open", "dashboard_loading") in events + assert ("open", "dashboard_content") in events + assert events.index(("info", "๊ฑฐ๋ž˜๋‚ด์—ญ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘โ€ฆ")) < events.index(("render", "๊ฑฐ๋ž˜๋‚ด์—ญ")) + assert events.index(("render", "๊ฑฐ๋ž˜๋‚ด์—ญ")) < len(events) - 1 + assert events.count(("empty", "placeholder")) >= 2 + + +def test_body_error_is_local_and_retry_rerenders_target(monkeypatch): + events = [] + monkeypatch.setattr(common.st, "empty", lambda: _FakePlaceholder(events)) + monkeypatch.setattr(common.st, "container", lambda **kwargs: _FakeKeyedContainer(events, kwargs["key"])) + monkeypatch.setattr(common.st, "info", lambda message: events.append(("info", message))) + monkeypatch.setattr(common.st, "error", lambda message: events.append(("error", message))) + monkeypatch.setattr(common.st, "button", lambda *args, **kwargs: events.append(("retry", args[0])) or True) + monkeypatch.setattr(common.st, "rerun", lambda: events.append(("rerun", "target"))) + common.render_dashboard_body("์š”์•ฝ", lambda: (_ for _ in ()).throw(RuntimeError("secret details"))) + assert ("error", "ํŽ˜์ด์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.") in events + assert ("rerun", "target") in events + assert all("secret details" not in str(event) for event in events) + + +def test_rapid_menu_clicks_commit_latest_selection_only(monkeypatch): + query = {"page": "์š”์•ฝ", "filter": "pending"} + state = {"dashboard_page": "์š”์•ฝ"} + callback = {} + monkeypatch.setattr(common.st, "query_params", query) + monkeypatch.setattr(common.st, "session_state", state) + monkeypatch.setattr(common.st, "radio", lambda *args, **kwargs: callback.setdefault("fn", kwargs["on_change"]) or state["dashboard_page"]) + common.render_sidebar_nav(PAGES, "์š”์•ฝ") + for page in ("๊ฑฐ๋ž˜๋‚ด์—ญ", "์ž์‚ฐ/๋งˆ๊ฐ", "Import"): + state["dashboard_page"] = page + callback["fn"]() + assert query == {"page": "Import", "filter": "pending"} + + +def test_refresh_control_preserves_cache_clear_contract(monkeypatch): + events = [] + + class ExpectedRerun(BaseException): + pass + + monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True) + monkeypatch.setattr(app, "get_db_path", lambda: "fixture.sqlite3") + monkeypatch.setattr(app, "inject_app_css", lambda: None) + monkeypatch.setattr(app.st, "query_params", {"page": "์š”์•ฝ"}) + monkeypatch.setattr(app, "render_sidebar_nav", lambda pages, page: page) + monkeypatch.setattr(app.st, "sidebar", pytest.importorskip("contextlib").nullcontext()) + monkeypatch.setattr(app.st, "button", lambda *args, **kwargs: True) + monkeypatch.setattr(app, "clear_cache", lambda: events.append("clear_cache")) + + def rerun(): + events.append("rerun") + raise ExpectedRerun + + monkeypatch.setattr(app.st, "rerun", rerun) + + with pytest.raises(ExpectedRerun): + app.main() + assert events == ["clear_cache", "rerun"] + + +class _FakePlaceholder: + def __init__(self, events): + self.events = events + + def container(self): + return _FakeContainer(self.events) + + def info(self, message): + self.events.append(("info", message)) + + def empty(self): + self.events.append(("empty", "placeholder")) + + +class _FakeContainer: + def __init__(self, events): + self.events = events + + def __enter__(self): + self.events.append(("container", "open")) + return self + + def __exit__(self, exc_type, exc, tb): + self.events.append(("container", "close")) + return False + + +class _FakeKeyedContainer: + def __init__(self, events, key): + self.events = events + self.key = key + + def __enter__(self): + self.events.append(("open", self.key)) + return self + + def __exit__(self, exc_type, exc, tb): + self.events.append(("close", self.key)) + return False + + def empty(self): + self.events.append(("empty", self.key)) diff --git a/tests/test_streamlit_browser_evidence.py b/tests/test_streamlit_browser_evidence.py new file mode 100644 index 0000000..b39a248 --- /dev/null +++ b/tests/test_streamlit_browser_evidence.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import json +import ssl +import sys +from pathlib import Path + +from unittest.mock import patch + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import streamlit_browser_evidence as evidence + + +SECRET_VALUES = ("synthetic-password", "a" * 64, "cookie-value") + + +def test_redact_secrets_removes_values_from_nested_manifest() -> None: + payload = {"url": "https://127.0.0.1/?token=cookie-value", "nested": ["synthetic-password", {"key": "a" * 64}]} + redacted = evidence.redact_secrets(payload, SECRET_VALUES) + serialized = json.dumps(redacted) + assert all(secret not in serialized for secret in SECRET_VALUES) + assert redacted["nested"][0] == "[REDACTED]" + + +def test_transition_requires_synthetic_fixture_database(tmp_path: Path) -> None: + with pytest.raises(SystemExit, match="fixture.sqlite3"): + evidence.assert_synthetic_db_path(tmp_path / "operating.sqlite3") + + +def test_validate_transition_manifest_requires_observations() -> None: + manifest = { + "mode": "transition", + "success": True, + "observations": {"frames": [], "navigation_events": []}, + } + with pytest.raises(ValueError, match="frames"): + evidence.validate_transition_manifest(manifest) + + +def complete_manifest() -> dict[str, object]: + return { + "mode": "transition", + "success": True, + "observations": { + "frames": [ + { + "at_ms": 0, + "selected_menu": "์š”์•ฝ", + "body_text_hash": "hash", + "loading": False, + "prior_body_visible": True, + }, + { + "at_ms": 100, + "selected_menu": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "body_text_hash": "hash2", + "loading": True, + "prior_body_visible": False, + }, + ], + "document_navigation_events": [], + "history_events": [{"type": "pushState", "url": "/?page=rules"}], + "stable_shell": { + "document_marker": {"pre": "doc-1", "post": "doc-1"}, + "sidebar_marker": {"pre": "side-1", "post": "side-1"}, + "background": {"pre": "rgb(255, 255, 255)", "post": "rgb(255, 255, 255)"}, + }, + "loading_observed": True, + "prior_body_removed": True, + "target_page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "final_agreement": True, + }, + "runtime": { + "viewport": {"width": 1440, "height": 1400}, + "interval_ms": 50, + "host_architecture": "aarch64", + "browser_user_agent": "Synthetic Browser", + }, + "source_revision": evidence.current_git_revision(), + "source_revision_kind": "git-head-base", + "source_worktree_dirty": evidence.git_worktree_dirty(("dashboard/app.py", "dashboard/common.py")), + "source_files": evidence.source_file_digests(("dashboard/app.py", "dashboard/common.py")), + "evidence_command": ["scripts/streamlit_browser_evidence.py", "--mode", "transition"], + "command": {"exit_code": 0, "teardown": "complete"}, + "limitations": [], + } + + +def test_validate_transition_manifest_accepts_complete_secret_free_shape() -> None: + evidence.validate_transition_manifest(complete_manifest()) + + +def test_wait_for_http_uses_verified_tls_by_default_and_fixture_context_opt_in() -> None: + response = type("Response", (), {"status": 200, "__enter__": lambda self: self, "__exit__": lambda *args: None})() + with patch.object(evidence.urllib.request, "urlopen", return_value=response) as urlopen: + evidence.wait_for_http("https://example.test") + assert urlopen.call_args.kwargs["context"] is None + fixture_context = ssl._create_unverified_context() + with patch.object(evidence.urllib.request, "urlopen", return_value=response) as urlopen: + evidence.wait_for_http("https://127.0.0.1", ssl_context=fixture_context) + assert urlopen.call_args.kwargs["context"] is fixture_context + + +def test_validate_transition_manifest_rejects_fabricated_navigation_event() -> None: + manifest = complete_manifest() + manifest["observations"]["document_navigation_events"] = [{"type": "load", "url": "/"}] + with pytest.raises(ValueError, match="document navigation"): + evidence.validate_transition_manifest(manifest) + + +def test_validate_transition_manifest_requires_observed_loading_without_prior_body() -> None: + no_loading = complete_manifest() + no_loading["observations"]["loading_observed"] = False + with pytest.raises(ValueError, match="loading"): + evidence.validate_transition_manifest(no_loading) + + stale_body = complete_manifest() + stale_body["observations"]["frames"][1]["prior_body_visible"] = True + with pytest.raises(ValueError, match="prior body"): + evidence.validate_transition_manifest(stale_body) + + +def test_validate_transition_manifest_requires_pre_post_shell_identity() -> None: + manifest = complete_manifest() + manifest["observations"]["stable_shell"] = {"sidebar": True, "background": "#fff"} + with pytest.raises(ValueError, match="shell"): + evidence.validate_transition_manifest(manifest) + + +def test_validate_transition_manifest_rejects_unselected_target_body_agreement() -> None: + manifest = complete_manifest() + manifest["observations"]["final_agreement"] = False + with pytest.raises(ValueError, match="agreement"): + evidence.validate_transition_manifest(manifest) + + +def test_validate_transition_manifest_rejects_attestation_before_teardown() -> None: + manifest = complete_manifest() + manifest["command"] = {"exit_code": 0, "teardown": "running"} + with pytest.raises(ValueError, match="teardown"): + evidence.validate_transition_manifest(manifest) + + +def test_validate_transition_manifest_requires_exact_source_and_runtime_identity() -> None: + manifest = complete_manifest() + manifest.pop("source_files") + with pytest.raises(ValueError, match="source files"): + evidence.validate_transition_manifest(manifest) + + manifest = complete_manifest() + manifest["runtime"].pop("browser_user_agent") + with pytest.raises(ValueError, match="runtime identity"): + evidence.validate_transition_manifest(manifest) + + manifest = complete_manifest() + manifest["source_files"]["dashboard/app.py"] = "0" * 64 + with pytest.raises(ValueError, match="source file digests"): + evidence.validate_transition_manifest(manifest) + + +def test_transition_manifest_rejects_secret_text() -> None: + manifest = {"mode": "transition", "success": False, "error": "synthetic-password"} + with pytest.raises(ValueError, match="secret"): + evidence.validate_transition_manifest(manifest, SECRET_VALUES)