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 0000000..f2db8b2
Binary files /dev/null and b/docs/evidence/issue-9-dashboard-menu-transition/final-state.jpg differ
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)