Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5813dd4841 | ||
|
|
37e8d18da6 | ||
|
|
9afea37424 | ||
|
|
1ebc53d152 | ||
|
|
f01dc182ec | ||
|
|
a02aceee41 | ||
|
|
96e11812ce |
+14
-6
@@ -12,7 +12,15 @@ if __package__ in (None, ""):
|
|||||||
|
|
||||||
st.set_page_config(page_title="Jarca Ledger", page_icon="💰", layout="wide")
|
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.authentication import require_dashboard_authentication
|
||||||
from dashboard.page_views.accounts import page_accounts
|
from dashboard.page_views.accounts import page_accounts
|
||||||
from dashboard.page_views.assets import page_assets
|
from dashboard.page_views.assets import page_assets
|
||||||
@@ -32,15 +40,15 @@ def main() -> None:
|
|||||||
db_path = get_db_path()
|
db_path = get_db_path()
|
||||||
inject_app_css()
|
inject_app_css()
|
||||||
pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]
|
pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]
|
||||||
query_page = st.query_params.get("page", "요약")
|
query_page = st.query_params.get("page")
|
||||||
page = query_page if query_page in pages else "요약"
|
page = validate_dashboard_page(query_page, pages)
|
||||||
st.session_state["page"] = page
|
st.session_state["dashboard_page"] = page
|
||||||
|
|
||||||
with st.sidebar:
|
with st.sidebar:
|
||||||
st.markdown("## 💰 Jarca Ledger")
|
st.markdown("## 💰 Jarca Ledger")
|
||||||
st.caption("가계부 · 분류 · Import 운영 콘솔")
|
st.caption("가계부 · 분류 · Import 운영 콘솔")
|
||||||
st.markdown('<div class="jarca-nav-title">메뉴</div>', unsafe_allow_html=True)
|
st.markdown('<div class="jarca-nav-title">메뉴</div>', 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):
|
if st.button("새로고침", key="refresh_button", use_container_width=True):
|
||||||
clear_cache()
|
clear_cache()
|
||||||
st.rerun()
|
st.rerun()
|
||||||
@@ -56,7 +64,7 @@ def main() -> None:
|
|||||||
"계좌": page_accounts,
|
"계좌": page_accounts,
|
||||||
"Import": page_imports,
|
"Import": page_imports,
|
||||||
}
|
}
|
||||||
dispatch[page](db_path)
|
render_dashboard_body(page, lambda: dispatch[page](db_path))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+42
-8
@@ -4,7 +4,6 @@ import sqlite3
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from html import escape
|
from html import escape
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
|
||||||
import importlib
|
import importlib
|
||||||
import inspect
|
import inspect
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
@@ -314,6 +313,9 @@ def inject_app_css() -> None:
|
|||||||
border-color: rgba(37, 99, 235, 0.48);
|
border-color: rgba(37, 99, 235, 0.48);
|
||||||
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.10);
|
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 {
|
.jarca-card {
|
||||||
border: 1px solid rgba(226, 232, 240, 0.86);
|
border: 1px solid rgba(226, 232, 240, 0.86);
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
@@ -355,13 +357,45 @@ def render_section_label(icon: str, label: str) -> None:
|
|||||||
st.markdown(f'<div class="jarca-section-label"><span>{escape(icon)}</span><span>{escape(label)}</span></div>', unsafe_allow_html=True)
|
st.markdown(f'<div class="jarca-section-label"><span>{escape(icon)}</span><span>{escape(label)}</span></div>', unsafe_allow_html=True)
|
||||||
|
|
||||||
|
|
||||||
def render_sidebar_nav(pages: list[str], current_page: str) -> None:
|
def validate_dashboard_page(page: str | None, pages: list[str]) -> str:
|
||||||
items = []
|
return page if page in pages else "요약"
|
||||||
for name in pages:
|
|
||||||
active = " active" if name == current_page else ""
|
|
||||||
href = f"?page={quote(name)}"
|
def _sync_dashboard_page_query() -> None:
|
||||||
items.append(f'<a class="jarca-nav-item{active}" href="{href}" target="_self">{escape(name)}</a>')
|
st.query_params["page"] = st.session_state["dashboard_page"]
|
||||||
st.markdown(f'<div class="jarca-nav-list">{"".join(items)}</div>', unsafe_allow_html=True)
|
|
||||||
|
|
||||||
|
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:
|
def parse_optional_int(value: str, field_name: str) -> int | None:
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ spec:
|
|||||||
type: RuntimeDefault
|
type: RuntimeDefault
|
||||||
initContainers:
|
initContainers:
|
||||||
- name: init-db
|
- name: init-db
|
||||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.3
|
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.4
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- python
|
- python
|
||||||
@@ -62,7 +62,7 @@ spec:
|
|||||||
mountPath: /tmp
|
mountPath: /tmp
|
||||||
containers:
|
containers:
|
||||||
- name: mcp
|
- name: mcp
|
||||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.3
|
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.4
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
env:
|
env:
|
||||||
- name: JARCA_LEDGER_DB_PATH
|
- name: JARCA_LEDGER_DB_PATH
|
||||||
@@ -104,7 +104,7 @@ spec:
|
|||||||
- name: mcp-tmp
|
- name: mcp-tmp
|
||||||
mountPath: /tmp
|
mountPath: /tmp
|
||||||
- name: dashboard
|
- name: dashboard
|
||||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.3
|
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.4
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- uvicorn
|
- uvicorn
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Issue #9 authenticated browser evidence
|
||||||
|
|
||||||
|
This directory binds the dashboard menu-transition acceptance evidence to the exact Git head and 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/computed-style frames at 50 ms intervals
|
||||||
|
- **Viewport:** 1280 × 720
|
||||||
|
- **Browser:** Headless Chrome 154; Client Hints architecture `arm`, bitness `64`
|
||||||
|
- **Fixture host:** Linux aarch64, Python 3.13.5, Streamlit 1.58.0, Uvicorn 0.49.0
|
||||||
|
|
||||||
|
## Verified browser observations
|
||||||
|
|
||||||
|
All twelve manifest checks are true:
|
||||||
|
|
||||||
|
- the HTML document and sidebar markers stayed identical;
|
||||||
|
- `[data-testid="stAppViewContainer"]` retained a non-`none` computed `backgroundImage` and remained visible/rendered in every frame;
|
||||||
|
- `[data-testid="stSidebar"]` retained a non-`none` computed `backgroundImage` and remained visible/rendered in every frame;
|
||||||
|
- computed `backgroundImage`, `backgroundColor`, `visibility`, `display`, `opacity`, and rendered geometry were captured for both surfaces in every frame;
|
||||||
|
- the click and all 35 frames completed inside one uninterrupted Playwright evaluation context, so no full-document navigation occurred;
|
||||||
|
- the target loading state was observed in two frames;
|
||||||
|
- the previous `요약` body was absent in every loading frame;
|
||||||
|
- the unrelated `source=evidence` query key survived;
|
||||||
|
- final URL, selected menu, target body, and cleared loading state agreed.
|
||||||
|
|
||||||
|
`browser-observations.json` contains every frame, body hashes, URL state, markers, computed surface styles, runtime metadata, and explicit limitations. `final-state.jpg` is the final visible browser state and 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=12/12 frames=35 full_document_loads=0 surfaces=2
|
||||||
|
```
|
||||||
|
|
||||||
|
The browser actions used Playwright MCP. That API reports success/error rather than a numeric process exit code; the manifest records this boundary instead of fabricating an exit status.
|
||||||
|
|
||||||
|
## Evidence boundaries
|
||||||
|
|
||||||
|
- This is synthetic-fixture browser evidence, not operating-ledger evidence.
|
||||||
|
- A temporary HTTPS ingress adapter normalized the request scheme. Repository authentication and same-origin enforcement remained active.
|
||||||
|
- The tunnel URL is intentionally ephemeral and retained only as provenance.
|
||||||
|
- The screenshot supplements, but does not replace, the machine-readable continuous-frame observations.
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"schema": "jarca.issue9.evidence-manifest.v2",
|
||||||
|
"issue": "jsfamily/jarca-ledger-mcp#9",
|
||||||
|
"captured_at": "2026-10-05T08:17:17.108Z",
|
||||||
|
"source": {
|
||||||
|
"git_head_base": "a02aceee416c5770dfd1f35ac94367f3c4e0c8c8",
|
||||||
|
"working_tree": true,
|
||||||
|
"files_sha256": {
|
||||||
|
"dashboard/app.py": "4c7b3a6264b4fb4ae0e445ddc04c625b295443816b198aa5a60615ac44846ff2",
|
||||||
|
"dashboard/common.py": "9a0b9dbb4ca88f27757fc293173821c48a56a30bc23f16b0f24731f246411c5a",
|
||||||
|
"scripts/streamlit_browser_evidence.py": "3cfb449ce1d6fae3abb795727d7ca1bc10dbb759bb811f121abd74fa42993dd7",
|
||||||
|
"tests/test_dashboard_menu_transition.py": "d23c35d88f6289b7c7e84a271df6ff3f6ff1f25a3a885aa696cc93db0b37035e",
|
||||||
|
"tests/test_streamlit_browser_evidence.py": "7f595ccf4009961225328486c19258bb0f7bc7dbfb584c60616860274112724a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"execution": {
|
||||||
|
"browser_tool": "mcp__playwright__browser_evaluate + mcp__playwright__browser_run_code_unsafe",
|
||||||
|
"tool_status": "success",
|
||||||
|
"exit_code": null,
|
||||||
|
"exit_code_note": "Playwright MCP reports success/error instead of a numeric process exit code.",
|
||||||
|
"transition": "요약 -> 분류 규칙",
|
||||||
|
"sampling_interval_ms": 50,
|
||||||
|
"frame_count": 35,
|
||||||
|
"viewport": {
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720
|
||||||
|
},
|
||||||
|
"browser": "HeadlessChrome/154.0.0.0",
|
||||||
|
"browser_architecture": {
|
||||||
|
"client_hint_architecture": "arm",
|
||||||
|
"client_hint_bitness": "64",
|
||||||
|
"navigator_platform": "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,
|
||||||
|
"app_surface_background_stable": true,
|
||||||
|
"sidebar_surface_background_stable": true,
|
||||||
|
"app_surface_visible_all_frames": true,
|
||||||
|
"sidebar_surface_visible_all_frames": true,
|
||||||
|
"surface_backgrounds_observed": 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": "414b62efb2f40483aae4227896040c1b5d887b2c31333f63046e6f34aec2fe8f",
|
||||||
|
"final-state.jpg": "cc4e1c28463f033ab1c26d1dc27c9bb93c6fc5495321c0cef73fdb1098221234"
|
||||||
|
},
|
||||||
|
"validator": {
|
||||||
|
"command": "python3 -c \"import hashlib,json,math,pathlib; r=pathlib.Path('.'); e=r/'docs/evidence/issue-9-dashboard-menu-transition'; m=json.loads((e/'manifest.json').read_text()); d=json.loads((e/'browser-observations.json').read_text()); frames=d['frames']; surfaces=('stAppViewContainer','stSidebar'); assert all(hashlib.sha256((r/p).read_bytes()).hexdigest()==h for p,h in m['source']['files_sha256'].items()); assert all(hashlib.sha256((e/p).read_bytes()).hexdigest()==h for p,h in m['artifacts_sha256'].items()); assert all(d['checks'].values()) and not d['document_load_events'] and len(frames)==35; assert all(isinstance(f['previous_body_visible'],bool) for f in frames); assert any(f['loading'] and f['previous_body_visible'] is False for f in frames); valid=lambda x: isinstance(x,dict) and str(x.get('backgroundImage','')).strip().lower() not in ('','none') and bool(str(x.get('backgroundColor','')).strip()) and x.get('visibility')=='visible' and x.get('display')!='none' and math.isfinite(float(x.get('opacity','nan'))) and float(x['opacity'])>0 and x.get('rendered') is True; assert all(valid(f['surfaces'][s]) for f in frames for s in surfaces); ss=d['stable_shell']; assert all(isinstance(ss[k]['pre'],str) and ss[k]['pre'] and ss[k]['pre']==ss[k]['post'] for k in ('document_marker','sidebar_marker','background')); assert ss['surfaces']['pre']==frames[0]['surfaces'] and ss['surfaces']['post']==d['post']['surfaces'] and ss['surfaces']['pre']==ss['surfaces']['post']; print('PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=%d full_document_loads=0 surfaces=2' % len(frames))\"",
|
||||||
|
"exit_code": 0,
|
||||||
|
"output": "PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=35 full_document_loads=0 surfaces=2"
|
||||||
|
},
|
||||||
|
"limitations": [
|
||||||
|
"Generated synthetic SQLite fixture only; no operating ledger data was used.",
|
||||||
|
"A temporary HTTPS ingress adapter normalized the trusted request scheme for repository same-origin authentication.",
|
||||||
|
"The ephemeral tunnel URL is expired provenance and is not a rerun target.",
|
||||||
|
"The same-document no-load assertion is bound to the uninterrupted Playwright evaluation context, stable document marker, and empty document-load event record."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
"environment": [
|
||||||
|
"os",
|
||||||
|
"kernel",
|
||||||
|
"architecture",
|
||||||
|
"python",
|
||||||
|
"uv"
|
||||||
|
],
|
||||||
|
"command_env_allowlist": [
|
||||||
|
"PATH",
|
||||||
|
"HOME",
|
||||||
|
"TMPDIR",
|
||||||
|
"LANG",
|
||||||
|
"VIRTUAL_ENV",
|
||||||
|
"UV_CACHE_DIR"
|
||||||
|
],
|
||||||
|
"secret_env_names": [],
|
||||||
|
"forbidden_regexes": [],
|
||||||
|
"hash_paths": [
|
||||||
|
"docs/evidence/issue-9-dashboard-menu-transition/README.md",
|
||||||
|
"docs/evidence/issue-9-dashboard-menu-transition/browser-observations.json",
|
||||||
|
"docs/evidence/issue-9-dashboard-menu-transition/final-state.jpg",
|
||||||
|
"docs/evidence/issue-9-dashboard-menu-transition/manifest.json",
|
||||||
|
"docs/project-structure.md",
|
||||||
|
"docs/requirements/req-002_dashboard-menu-transition.md",
|
||||||
|
"docs/runtime-flow.md",
|
||||||
|
"scripts/streamlit_browser_evidence.py",
|
||||||
|
"tests/test_streamlit_browser_evidence.py"
|
||||||
|
],
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"id": "full-suite",
|
||||||
|
"purpose": "Run the repository full test suite at the rework source revision",
|
||||||
|
"argv": [
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"--extra",
|
||||||
|
"dashboard",
|
||||||
|
"--extra",
|
||||||
|
"dev",
|
||||||
|
"pytest"
|
||||||
|
],
|
||||||
|
"expected_exit_code": 0,
|
||||||
|
"max_output_bytes": 65536,
|
||||||
|
"claims": [
|
||||||
|
"full-suite-result"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "browser-evidence-validator",
|
||||||
|
"purpose": "Validate checked-in authenticated browser evidence, source and artifact hashes",
|
||||||
|
"argv": [
|
||||||
|
"python3",
|
||||||
|
"-c",
|
||||||
|
"import hashlib,json,math,pathlib; r=pathlib.Path('.'); e=r/'docs/evidence/issue-9-dashboard-menu-transition'; m=json.loads((e/'manifest.json').read_text()); d=json.loads((e/'browser-observations.json').read_text()); frames=d['frames']; surfaces=('stAppViewContainer','stSidebar'); assert all(hashlib.sha256((r/p).read_bytes()).hexdigest()==h for p,h in m['source']['files_sha256'].items()); assert all(hashlib.sha256((e/p).read_bytes()).hexdigest()==h for p,h in m['artifacts_sha256'].items()); assert all(d['checks'].values()) and not d['document_load_events'] and len(frames)==35; assert all(isinstance(f['previous_body_visible'],bool) for f in frames); assert any(f['loading'] and f['previous_body_visible'] is False for f in frames); valid=lambda x: isinstance(x,dict) and str(x.get('backgroundImage','')).strip().lower() not in ('','none') and bool(str(x.get('backgroundColor','')).strip()) and x.get('visibility')=='visible' and x.get('display')!='none' and math.isfinite(float(x.get('opacity','nan'))) and float(x['opacity'])>0 and x.get('rendered') is True; assert all(valid(f['surfaces'][s]) for f in frames for s in surfaces); ss=d['stable_shell']; assert all(isinstance(ss[k]['pre'],str) and ss[k]['pre'] and ss[k]['pre']==ss[k]['post'] for k in ('document_marker','sidebar_marker','background')); assert ss['surfaces']['pre']==frames[0]['surfaces'] and ss['surfaces']['post']==d['post']['surfaces'] and ss['surfaces']['pre']==ss['surfaces']['post']; print('PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=%d full_document_loads=0 surfaces=2' % len(frames))"
|
||||||
|
],
|
||||||
|
"expected_exit_code": 0,
|
||||||
|
"max_output_bytes": 8192,
|
||||||
|
"claims": [
|
||||||
|
"browser-evidence-result"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "documentation-inventory",
|
||||||
|
"purpose": "Validate canonical requirement and documentation remediation coverage",
|
||||||
|
"argv": [
|
||||||
|
"python3",
|
||||||
|
"-c",
|
||||||
|
"from pathlib import Path\nimport re\nq=Path('docs/requirements/req-002_dashboard-menu-transition.md').read_text(); p=Path('docs/project-structure.md').read_text(); f=Path('docs/runtime-flow.md').read_text()\nrb=re.split(r'(?=^### REQ-002\\.\\d{2}(?!-AC)\\b)',q,flags=re.M)[1:]; ab=re.split(r'(?=^### REQ-002\\.\\d{2}-AC01\\b)',q,flags=re.M)[1:]\nassert len(rb)==len(ab)==9 and all('**Impact**' in x for x in rb) and all('**Cases**' in x and '**State Invariants**' in x for x in ab)\nassert all(x in q for x in ('REQ-001.05-AC01','REQ-001.07-AC01','REQ-001.08-AC01','REQ-001.09-AC01'))\nassert all(x in p for x in ('dashboard/common.py','inject_app_css()','JARCA_COLORS','JARCA_PLOTLY_TEMPLATE','outside the replaceable body'))\nassert all(x in f for x in ('do not initiate or replay financial writes or external calls','explicit page user actions','no new idempotency contract'))\nprint('PASS requirements=9 impact=9 acceptance_criteria=9 cases=9 state_invariants=9 missing=0 ambiguous=0')"
|
||||||
|
],
|
||||||
|
"expected_exit_code": 0,
|
||||||
|
"max_output_bytes": 8192,
|
||||||
|
"claims": [
|
||||||
|
"documentation-contract-result"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ruff",
|
||||||
|
"purpose": "Run static lint checks",
|
||||||
|
"argv": [
|
||||||
|
"uv",
|
||||||
|
"run",
|
||||||
|
"--extra",
|
||||||
|
"dashboard",
|
||||||
|
"--extra",
|
||||||
|
"dev",
|
||||||
|
"ruff",
|
||||||
|
"check",
|
||||||
|
"dashboard",
|
||||||
|
"tests",
|
||||||
|
"scripts"
|
||||||
|
],
|
||||||
|
"expected_exit_code": 0,
|
||||||
|
"max_output_bytes": 8192,
|
||||||
|
"claims": [
|
||||||
|
"lint-result"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "compile",
|
||||||
|
"purpose": "Compile Python sources and tests",
|
||||||
|
"argv": [
|
||||||
|
"python3",
|
||||||
|
"-m",
|
||||||
|
"compileall",
|
||||||
|
"-q",
|
||||||
|
"dashboard",
|
||||||
|
"tests",
|
||||||
|
"scripts"
|
||||||
|
],
|
||||||
|
"expected_exit_code": 0,
|
||||||
|
"max_output_bytes": 8192,
|
||||||
|
"claims": [
|
||||||
|
"compile-result"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"The authenticated browser transition was captured before this clean source commit from byte-identical hashed files; this manifest validates the tracked browser artifacts and their exact source hashes rather than rerunning the browser.",
|
||||||
|
"Gitea commit status and Actions state are authoritative external readbacks and are not produced by this local manifest."
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -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
|
- 실패 계약: wrong credential, invalid configuration, cross-origin, malformed cookie tests
|
||||||
- 경계 계약: expiry, rotation, oversized token, same-host different-port origin 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
|
- 구현·스키마·예시 정합성: `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에는 `<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`까지를 따른다.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ dashboard/
|
|||||||
├── auth_server.py
|
├── auth_server.py
|
||||||
├── authentication.py
|
├── authentication.py
|
||||||
├── app.py
|
├── app.py
|
||||||
|
├── common.py
|
||||||
└── page_views/
|
└── page_views/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -25,9 +26,12 @@ dashboard/
|
|||||||
| `dashboard/server.py` | source | ASGI app 조립 | `app` | route wiring only | source review required | developer |
|
| `dashboard/server.py` | source | ASGI app 조립 | `app` | route wiring only | source review required | developer |
|
||||||
| `dashboard/auth_server.py` | source | login/cleanup/logout response | `create_auth_routes()` | no DB/page imports | source review required | developer |
|
| `dashboard/auth_server.py` | source | login/cleanup/logout response | `create_auth_routes()` | no DB/page imports | source review required | developer |
|
||||||
| `dashboard/authentication.py` | source | config, constant-time compare, token verify, gate | `require_dashboard_authentication()` | server-only secret use | source review required | developer |
|
| `dashboard/authentication.py` | source | config, constant-time compare, token verify, gate | `require_dashboard_authentication()` | server-only secret use | source review required | developer |
|
||||||
| `dashboard/app.py` | source | gate then page dispatch | `main()` | no token signing details | source review required | developer |
|
| `dashboard/app.py` | source | auth gate first, then DB path resolution, common shell/sidebar/refresh assembly, and target body renderer dispatch | `main()` | shell/background remain outside the replaceable body; no token signing details | source review required | developer |
|
||||||
|
| `dashboard/common.py` | source | shared Dashboard shell, menu/query transition, stable body container, loading/error/retry rendering, and app background/style | `render_sidebar_nav()`, `render_dashboard_body()`, `inject_app_css()` | no authentication transport or financial write orchestration | source review required | developer |
|
||||||
| `dashboard/page_views/` | source | financial UI pages | `page_*` functions | no auth transport calls | source review required | developer |
|
| `dashboard/page_views/` | source | financial UI pages | `page_*` functions | no auth transport calls | source review required | developer |
|
||||||
|
|
||||||
|
`dashboard/common.py` owns the shared visual theme through `JARCA_COLORS`, `JARCA_PLOTLY_TEMPLATE`, and `inject_app_css()`. Page renderers consume that theme but do not own or replace the authenticated shell/sidebar background.
|
||||||
|
|
||||||
### verification-tests — 인증 검증
|
### verification-tests — 인증 검증
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -35,6 +39,8 @@ tests/
|
|||||||
├── test_dashboard_authentication.py
|
├── test_dashboard_authentication.py
|
||||||
├── test_dashboard_persistent_authentication.py
|
├── test_dashboard_persistent_authentication.py
|
||||||
├── test_dashboard_auth_server.py
|
├── test_dashboard_auth_server.py
|
||||||
|
├── test_dashboard_menu_transition.py
|
||||||
|
├── test_streamlit_browser_evidence.py
|
||||||
└── test_dashboard_authentication_app.py
|
└── test_dashboard_authentication_app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -43,12 +49,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_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_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_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 |
|
| `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 — 운영 문서
|
### deployment-docs — 운영 문서
|
||||||
|
|
||||||
```text
|
```text
|
||||||
docs/requirements/req-001_dashboard-administrator-authentication.md
|
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/interface-contract.md
|
||||||
docs/runtime-flow.md
|
docs/runtime-flow.md
|
||||||
README.md
|
README.md
|
||||||
@@ -58,6 +75,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-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/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 |
|
| `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 |
|
| `README.md` | operations | local/deployment constraints | operator commands | no secret values | operational review | developer |
|
||||||
@@ -71,15 +89,16 @@ deploy/k8s/README.md
|
|||||||
| `dashboard.server` | `dashboard.app` | 허용 | app remains protected by gate | `docs/runtime-flow.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.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.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` |
|
| `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 |
|
| 이름·패키지 | 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 |
|
| 생성 산출물 | evidence may be redacted; never commit credentials, secrets, tokens, or cookie values |
|
||||||
|
|
||||||
## 검증
|
## 검증
|
||||||
@@ -98,4 +117,4 @@ deploy/k8s/README.md
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 정적 분석 기대 결과: exit 0 with no diagnostics
|
- 정적 분석 기대 결과: 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
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
---
|
||||||
|
{
|
||||||
|
"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와 일치한다.
|
||||||
|
|
||||||
|
**Impact**
|
||||||
|
|
||||||
|
- Interfaces: `dashboard_page` radio and `page` query parameter
|
||||||
|
- Persistence: none
|
||||||
|
- Consumers: dashboard page renderers
|
||||||
|
- Migration: none
|
||||||
|
- Compatibility: existing labels, order, and selected-state contract
|
||||||
|
- Deployment prerequisites: none
|
||||||
|
|
||||||
|
### REQ-002.02 — URL과 직접 접근
|
||||||
|
|
||||||
|
메뉴 선택은 query의 `page`만 대상 label로 갱신하고 `month`, `account_id`, `filter` 등 sibling query key를 보존한다. 직접 `?page=<known>`은 해당 page를 렌더링하고, missing/unknown 값은 `요약`으로 fallback한다.
|
||||||
|
|
||||||
|
**Impact**
|
||||||
|
|
||||||
|
- Interfaces: browser query parameters
|
||||||
|
- Persistence: none
|
||||||
|
- Consumers: direct links and page renderers
|
||||||
|
- Migration: none
|
||||||
|
- Compatibility: sibling query keys and direct page access
|
||||||
|
- Deployment prerequisites: none
|
||||||
|
|
||||||
|
### REQ-002.03 — 연속 화면
|
||||||
|
|
||||||
|
메뉴 클릭은 document navigation을 일으키지 않는다. 공통 shell과 background는 유지하고 body만 선택된 renderer의 내용으로 교체한다. history/back-forward와 path URL은 이 요구사항의 판정 범위가 아니다.
|
||||||
|
|
||||||
|
**Impact**
|
||||||
|
|
||||||
|
- Interfaces: Streamlit shell/body rendering
|
||||||
|
- Persistence: none
|
||||||
|
- Consumers: authenticated dashboard users
|
||||||
|
- Migration: none
|
||||||
|
- Compatibility: existing shell, background, and path behavior
|
||||||
|
- Deployment prerequisites: none
|
||||||
|
|
||||||
|
### REQ-002.04 — 전환 중 표시
|
||||||
|
|
||||||
|
느린 renderer가 실행되는 동안 target page label을 포함한 `불러오는 중…` loading을 대상 body 영역에 표시하고, renderer 완료 후 loading placeholder를 제거한다.
|
||||||
|
|
||||||
|
**Impact**
|
||||||
|
|
||||||
|
- Interfaces: target body loading state
|
||||||
|
- Persistence: none
|
||||||
|
- Consumers: dashboard users
|
||||||
|
- Migration: none
|
||||||
|
- Compatibility: existing renderer output after completion
|
||||||
|
- Deployment prerequisites: none
|
||||||
|
|
||||||
|
### REQ-002.05 — local failure와 retry
|
||||||
|
|
||||||
|
target renderer 예외는 공통 shell을 무너뜨리지 않고 body 영역을 generic error와 `다시 시도` control로 교체한다. 예외 상세·secret은 UI에 노출하지 않는다.
|
||||||
|
|
||||||
|
**Impact**
|
||||||
|
|
||||||
|
- Interfaces: body error and retry control
|
||||||
|
- Persistence: none
|
||||||
|
- Consumers: dashboard users and support operators
|
||||||
|
- Migration: none
|
||||||
|
- Compatibility: shell remains available; secrets remain redacted
|
||||||
|
- Deployment prerequisites: none
|
||||||
|
|
||||||
|
### REQ-002.06 — 최신 선택 우선
|
||||||
|
|
||||||
|
연속 메뉴 선택은 serialized callback 순서의 마지막 선택을 query와 body에 반영하며 이전 선택이 마지막 상태를 덮어쓰지 않는다.
|
||||||
|
|
||||||
|
**Impact**
|
||||||
|
|
||||||
|
- Interfaces: serialized menu callback and query state
|
||||||
|
- Persistence: none
|
||||||
|
- Consumers: page selection state
|
||||||
|
- Migration: none
|
||||||
|
- Compatibility: latest-selection ordering contract
|
||||||
|
- Deployment prerequisites: none
|
||||||
|
|
||||||
|
### REQ-002.07 — 인증 경계 보존
|
||||||
|
|
||||||
|
모든 menu/page dispatch는 REQ-001 gate와 cookie 검증 뒤에만 실행된다. no-cookie/invalid-cookie는 page body에 도달하지 않으며 valid cookie는 기존 Dashboard에 접근한다.
|
||||||
|
|
||||||
|
**Impact**
|
||||||
|
|
||||||
|
- Interfaces: REQ-001 authentication gate and cookie verification
|
||||||
|
- Persistence: none
|
||||||
|
- Consumers: authenticated dashboard dispatch
|
||||||
|
- Migration: none
|
||||||
|
- Compatibility: REQ-001 expiry, rotation, cleanup, and logout behavior
|
||||||
|
- Deployment prerequisites: existing REQ-001 settings
|
||||||
|
|
||||||
|
### REQ-002.08 — 금융 page 회귀 없음
|
||||||
|
|
||||||
|
요약, 자산/마감, 분류 규칙, 확인필요 큐, 월정산의 기존 synthetic fixture DB 핵심 내용과 기존 page query 계약을 유지한다.
|
||||||
|
|
||||||
|
**Impact**
|
||||||
|
|
||||||
|
- Interfaces: existing page/query contracts
|
||||||
|
- Persistence: existing synthetic fixture DB reads and writes
|
||||||
|
- Consumers: financial page renderers
|
||||||
|
- Migration: none
|
||||||
|
- Compatibility: existing financial calculations and gateway semantics
|
||||||
|
- Deployment prerequisites: existing fixture and page setup
|
||||||
|
|
||||||
|
### REQ-002.09 — 운영 control 회귀 없음
|
||||||
|
|
||||||
|
refresh control은 기존 `clear_cache` callable 계약을 유지하고, 메뉴 전환은 cache-clear 동작을 변경하지 않는다.
|
||||||
|
|
||||||
|
**Impact**
|
||||||
|
|
||||||
|
- Interfaces: `clear_cache` callable and refresh control
|
||||||
|
- Persistence: none
|
||||||
|
- Consumers: dashboard operators
|
||||||
|
- Migration: none
|
||||||
|
- Compatibility: existing cache-clear behavior
|
||||||
|
- Deployment prerequisites: none
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
**Cases**: positive, unchanged-state
|
||||||
|
|
||||||
|
**State Invariants**: labels/order and selected state remain exact.
|
||||||
|
|
||||||
|
**판정 기준**
|
||||||
|
|
||||||
|
정확한 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
|
||||||
|
|
||||||
|
**Cases**: positive, boundary, unchanged-state
|
||||||
|
|
||||||
|
**State Invariants**: only `page` changes; sibling query keys remain unchanged.
|
||||||
|
|
||||||
|
**판정 기준**
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
**Cases**: positive, unchanged-state
|
||||||
|
|
||||||
|
**State Invariants**: document, shell, and background remain stable; body changes only.
|
||||||
|
|
||||||
|
**판정 기준**
|
||||||
|
|
||||||
|
실제 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
|
||||||
|
|
||||||
|
**Cases**: positive, boundary
|
||||||
|
|
||||||
|
**State Invariants**: loading appears before completion and is removed after rendering.
|
||||||
|
|
||||||
|
**판정 기준**
|
||||||
|
|
||||||
|
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는 실제 authenticated transition을 50 ms 간격으로 표본화했으며 loading은 2개 frame에서 관찰됐다. 표본 사이의 모든 순간을 보장하지 않는다.
|
||||||
|
|
||||||
|
### REQ-002.05-AC01 — local error/retry
|
||||||
|
|
||||||
|
**Cases**: negative, positive
|
||||||
|
|
||||||
|
**State Invariants**: shell remains intact; renderer details and secrets are not exposed.
|
||||||
|
|
||||||
|
**판정 기준**
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
**Cases**: concurrency, unchanged-state
|
||||||
|
|
||||||
|
**State Invariants**: latest serialized selection is final.
|
||||||
|
|
||||||
|
**판정 기준**
|
||||||
|
|
||||||
|
연속 선택 `거래내역` → `자산/마감` → `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
|
||||||
|
|
||||||
|
**Cases**: negative, positive
|
||||||
|
|
||||||
|
**State Invariants**: auth gate precedes DB/page dispatch; cookie semantics remain REQ-001.
|
||||||
|
|
||||||
|
**판정 기준**
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
**Cases**: positive, unchanged-state
|
||||||
|
|
||||||
|
**State Invariants**: existing financial page results and query contracts remain unchanged.
|
||||||
|
|
||||||
|
**판정 기준**
|
||||||
|
|
||||||
|
기존 핵심 금융 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
|
||||||
|
|
||||||
|
**Cases**: positive, unchanged-state
|
||||||
|
|
||||||
|
**State Invariants**: `clear_cache` remains callable and menu transition does not alter it.
|
||||||
|
|
||||||
|
**판정 기준**
|
||||||
|
|
||||||
|
refresh control이 callable `clear_cache` 계약을 유지한다.
|
||||||
|
|
||||||
|
**검증**
|
||||||
|
|
||||||
|
`tests/test_dashboard_menu_transition.py::test_refresh_control_preserves_cache_clear_contract`
|
||||||
|
|
||||||
|
**검증 분류**
|
||||||
|
|
||||||
|
- 수준: regression unit
|
||||||
|
- 백엔드: pytest
|
||||||
|
- 증거: callable/name assertion
|
||||||
|
|
||||||
|
### Acceptance Criteria canonical fields
|
||||||
|
|
||||||
|
Each Acceptance Criterion has explicit `Cases` and `State Invariants` in addition to the existing Korean verification text.
|
||||||
|
|
||||||
|
| Acceptance Criterion | Cases | State Invariants |
|
||||||
|
|---|---|---|
|
||||||
|
| `REQ-002.01-AC01` | positive, unchanged-state | labels/order and selected state remain exact |
|
||||||
|
| `REQ-002.02-AC01` | positive, boundary, unchanged-state | only `page` changes; sibling query keys remain unchanged |
|
||||||
|
| `REQ-002.03-AC01` | positive, unchanged-state | document, shell, and background remain stable; body changes only |
|
||||||
|
| `REQ-002.04-AC01` | positive, boundary | loading appears before completion and is removed after rendering |
|
||||||
|
| `REQ-002.05-AC01` | negative, positive | shell remains intact; renderer details and secrets are not exposed |
|
||||||
|
| `REQ-002.06-AC01` | concurrency, unchanged-state | latest serialized selection is final |
|
||||||
|
| `REQ-002.07-AC01` | negative, positive | auth gate precedes DB/page dispatch; cookie semantics remain REQ-001 |
|
||||||
|
| `REQ-002.08-AC01` | positive, unchanged-state | existing financial page results and query contracts remain unchanged |
|
||||||
|
| `REQ-002.09-AC01` | positive, unchanged-state | `clear_cache` remains callable and menu transition does not alter it |
|
||||||
|
|
||||||
|
## 8. 오류 및 충돌 계약
|
||||||
|
|
||||||
|
인증 실패는 항상 REQ-001 gate가 우선하며 메뉴·query로 우회할 수 없다. renderer 실패는 해당 body만 generic error/retry로 대체한다. 빠른 선택은 Streamlit serialized callback 순서를 따르며 마지막 serialized selection이 최종 상태다. history/back-forward와 path URL은 요구하지 않는다.
|
||||||
|
|
||||||
|
## 9. 데이터 및 인터페이스 계약
|
||||||
|
|
||||||
|
- `dashboard_page` is the canonical selected menu field; `page` is the only query field written by menu selection.
|
||||||
|
- `month`, `account_id`, `filter`, and other sibling query keys are preserved verbatim.
|
||||||
|
- Body loading, error, retry, and renderer output are local to the stable body container; shell/background are not replaced.
|
||||||
|
|
||||||
|
## 10. 하위호환성
|
||||||
|
|
||||||
|
- Authentication expiry, rotation, invalid-cookie cleanup, and explicit logout remain governed by `REQ-001.05-AC01`, `REQ-001.07-AC01`, `REQ-001.08-AC01`, and `REQ-001.09-AC01`.
|
||||||
|
- Existing exact tests remain the authority: `tests/test_dashboard_persistent_authentication.py::test_token_expiry_is_absolute_and_not_sliding`, `tests/test_dashboard_persistent_authentication.py::test_tampering_and_rotations_invalidate_token`, and `tests/test_dashboard_auth_server.py::test_logout_clears_cookie_with_matching_scope`.
|
||||||
|
- Menu/query reruns/rendering do not initiate or replay financial writes or external calls. Financial writes and external calls remain behind explicit page user actions; this adds no new idempotency contract.
|
||||||
|
|
||||||
|
## 11. 검증 계획
|
||||||
|
|
||||||
|
- Menu transition tests and required browser evidence verify REQ-002 criteria; REQ-001 authentication tests above verify expiry, rotation, and logout regressions.
|
||||||
|
|
||||||
|
### Test Traceability Contract
|
||||||
|
|
||||||
|
Test definitions reference the exact Acceptance Criterion IDs and execution evidence records revision, commit, environment, and result separately.
|
||||||
|
|
||||||
|
## 12. 추적성
|
||||||
|
|
||||||
|
- 근거 이슈: `#9`
|
||||||
|
- 구현 PR: `#10`
|
||||||
|
- 관련 설계 문서: `docs/project-structure.md`, `docs/runtime-flow.md`
|
||||||
|
- 관련 requirement: `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`
|
||||||
|
|
||||||
|
## 13. 확정된 결정
|
||||||
|
|
||||||
|
- Keep the authenticated shell and background stable while replacing only the body.
|
||||||
|
- Preserve REQ-001 authentication semantics and keep financial writes/external calls behind explicit page actions.
|
||||||
|
|
||||||
|
## 14. Open Questions
|
||||||
|
|
||||||
|
- 없음
|
||||||
|
|
||||||
|
## 15. 승인 기록
|
||||||
|
|
||||||
|
- 상태: `approved`
|
||||||
|
- 승인 주체: existing requirement approval
|
||||||
|
- 승인 근거: `REQ-002` revision `1.0`
|
||||||
@@ -112,3 +112,34 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo
|
|||||||
- 경계 흐름: TTL boundary, rotation, oversized token, same-host different-port origin
|
- 경계 흐름: TTL boundary, rotation, oversized token, same-host different-port origin
|
||||||
- 동시성 흐름: independent failed submissions and no application lock; ingress rate-limit configuration review
|
- 동시성 흐름: 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
|
- 운영 진입점과 기대 결과: `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.
|
||||||
|
- menu/query reruns and rendering do not initiate or replay financial writes or external calls; those remain behind explicit page user actions, with no new idempotency contract.
|
||||||
|
- 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하는지 검증한다.
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Jarca Ledger MCP server."""
|
"""Jarca Ledger MCP server."""
|
||||||
|
|
||||||
__version__ = "0.1.3"
|
__version__ = "0.1.4"
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jarca-ledger-mcp"
|
name = "jarca-ledger-mcp"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis"
|
description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -14,10 +14,16 @@ import asyncio
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
|
import hashlib
|
||||||
|
import math
|
||||||
|
import secrets
|
||||||
|
import ssl
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -44,6 +50,13 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--output-dir", default="", help="Evidence output directory. Defaults under docs/batches/BAT-004/evidence/run-<timestamp>.")
|
parser.add_argument("--output-dir", default="", help="Evidence output directory. Defaults under docs/batches/BAT-004/evidence/run-<timestamp>.")
|
||||||
parser.add_argument("--page", action="append", dest="pages", help="Page to capture. Repeatable. Defaults to core E2E evidence pages.")
|
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("--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()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -145,12 +158,13 @@ def seed_fixture_db(db_path: Path) -> dict[str, Any]:
|
|||||||
return {"account_id": account_id, "transaction_ids": transactions, "db_path": str(db_path)}
|
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
|
deadline = time.time() + timeout_seconds
|
||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
try:
|
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:
|
if response.status == 200:
|
||||||
return
|
return
|
||||||
last_error = f"HTTP {response.status}"
|
last_error = f"HTTP {response.status}"
|
||||||
@@ -195,6 +209,7 @@ class CdpClient:
|
|||||||
def __init__(self, ws: websockets.ClientConnection) -> None:
|
def __init__(self, ws: websockets.ClientConnection) -> None:
|
||||||
self.ws = ws
|
self.ws = ws
|
||||||
self.next_id = 1
|
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]:
|
async def call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
command_id = self.next_id
|
command_id = self.next_id
|
||||||
@@ -207,6 +222,8 @@ class CdpClient:
|
|||||||
if "error" in payload:
|
if "error" in payload:
|
||||||
raise RuntimeError(f"CDP {method} failed: {payload['error']}")
|
raise RuntimeError(f"CDP {method} failed: {payload['error']}")
|
||||||
return payload.get("result", {})
|
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]:
|
async def cdp_capture(ws_url: str, url: str, expected_text: str, screenshot: Path, snapshot: Path) -> dict[str, Any]:
|
||||||
@@ -295,10 +312,233 @@ 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_surface_records(records: Any, label: str) -> None:
|
||||||
|
required = ("stAppViewContainer", "stSidebar")
|
||||||
|
if not isinstance(records, dict) or any(not isinstance(records.get(name), dict) for name in required):
|
||||||
|
raise ValueError(f"transition observations require surface records for {label}")
|
||||||
|
for name in required:
|
||||||
|
surface = records[name]
|
||||||
|
if any(key not in surface or surface[key] is None for key in ("backgroundImage", "backgroundColor", "visibility", "display", "opacity", "rendered")):
|
||||||
|
raise ValueError(f"transition observations require complete surface records for {label}")
|
||||||
|
if not str(surface["backgroundImage"]).strip() or str(surface["backgroundImage"]).strip().lower() == "none":
|
||||||
|
raise ValueError(f"transition observations reject empty surface background for {label}")
|
||||||
|
if not str(surface["backgroundColor"]).strip():
|
||||||
|
raise ValueError(f"transition observations reject empty surface background color for {label}")
|
||||||
|
try:
|
||||||
|
opacity = float(surface["opacity"])
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(f"transition observations require valid surface opacity for {label}") from exc
|
||||||
|
if not math.isfinite(opacity) or surface["visibility"] != "visible" or surface["display"] == "none" or opacity <= 0 or surface["rendered"] is not True:
|
||||||
|
raise ValueError(f"transition observations require visible rendered surfaces for {label}")
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
for index, frame in enumerate(frames):
|
||||||
|
_validate_surface_records(frame.get("surfaces"), f"frame {index}")
|
||||||
|
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 {}
|
||||||
|
stable_fields = ("document_marker", "sidebar_marker", "background")
|
||||||
|
if any(not isinstance(shell.get(key), dict) for key in stable_fields):
|
||||||
|
raise ValueError("transition observations require pre/post stable shell markers")
|
||||||
|
if any(
|
||||||
|
not isinstance(values.get(position), str) or not values[position].strip()
|
||||||
|
for values in (shell[key] for key in stable_fields)
|
||||||
|
for position in ("pre", "post")
|
||||||
|
):
|
||||||
|
raise ValueError("transition observations require non-empty stable shell markers")
|
||||||
|
if any(shell[key]["pre"] != shell[key]["post"] for key in stable_fields):
|
||||||
|
raise ValueError("transition observations require stable shell identity and background")
|
||||||
|
stable_surfaces = shell.get("surfaces")
|
||||||
|
if not isinstance(stable_surfaces, dict) or stable_surfaces.get("pre") != frames[0].get("surfaces"):
|
||||||
|
raise ValueError("transition observations require stable shell surface records")
|
||||||
|
_validate_surface_records(stable_surfaces.get("pre"), "stable shell pre")
|
||||||
|
_validate_surface_records(stable_surfaces.get("post"), "stable shell post")
|
||||||
|
if stable_surfaces["pre"] != stable_surfaces["post"] or stable_surfaces["post"] != frames[-1].get("surfaces"):
|
||||||
|
raise ValueError("transition observations require stable shell surface consistency")
|
||||||
|
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();
|
||||||
|
window.__evidenceSurface = (selector) => { const element=document.querySelector(selector); if (!element) return null; const style=getComputedStyle(element); const rect=element.getBoundingClientRect(); return {backgroundImage:style.backgroundImage, backgroundColor:style.backgroundColor, visibility:style.visibility, display:style.display, opacity:style.opacity, rendered:rect.width>0 && rect.height>0 && style.visibility!=='hidden' && style.display!=='none' && Number(style.opacity)>0}; };
|
||||||
|
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, surfaces:{stAppViewContainer:window.__evidenceSurface('[data-testid=stAppViewContainer]'), stSidebar:window.__evidenceSurface('[data-testid=stSidebar]')}, 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, surfaces:{stAppViewContainer:window.__evidenceSurface('[data-testid=stAppViewContainer]'), stSidebar:window.__evidenceSurface('[data-testid=stSidebar]')}, 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")}, "surfaces": {"pre": pre.get("surfaces"), "post": data.get("surfaces")}}, "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:
|
def main() -> int:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
chrome = require_executable(args.chrome, "Chrome")
|
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")
|
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 = Path(args.output_dir) if args.output_dir else DEFAULT_OUTPUT_ROOT / run_id
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -308,56 +548,105 @@ def main() -> int:
|
|||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["JARCA_LEDGER_DB_PATH"] = str(db_path.resolve())
|
env["JARCA_LEDGER_DB_PATH"] = str(db_path.resolve())
|
||||||
env["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
env["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
||||||
base_url = f"http://{args.host}:{args.port}"
|
transition_secrets: tuple[str, ...] = ()
|
||||||
server_cmd = [
|
if args.mode == "transition":
|
||||||
streamlit,
|
if not args.cert_file or not args.key_file:
|
||||||
"run",
|
raise SystemExit("transition mode requires --cert-file and --key-file")
|
||||||
"dashboard/app.py",
|
env["JARCA_DASHBOARD_ADMIN_ID"] = secrets.token_urlsafe(18)
|
||||||
"--server.address",
|
env["JARCA_DASHBOARD_ADMIN_PASSWORD"] = secrets.token_urlsafe(32)
|
||||||
args.host,
|
env["JARCA_DASHBOARD_SIGNING_SECRET"] = secrets.token_hex(32)
|
||||||
"--server.port",
|
transition_secrets = (env["JARCA_DASHBOARD_ADMIN_ID"], env["JARCA_DASHBOARD_ADMIN_PASSWORD"], env["JARCA_DASHBOARD_SIGNING_SECRET"])
|
||||||
str(args.port),
|
scheme = "https" if args.mode == "transition" else "http"
|
||||||
"--server.headless",
|
base_url = f"{scheme}://{args.host}:{args.port}"
|
||||||
"true",
|
if args.mode == "transition":
|
||||||
"--browser.gatherUsageStats",
|
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]
|
||||||
"false",
|
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)
|
server = subprocess.Popen(server_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, start_new_session=True)
|
||||||
manifest: dict[str, Any] = {
|
manifest: dict[str, Any] = {
|
||||||
"success": False,
|
"success": False,
|
||||||
|
"mode": args.mode,
|
||||||
"base_url": base_url,
|
"base_url": base_url,
|
||||||
"output_dir": str(output_dir.resolve()),
|
"output_dir": str(output_dir.resolve()),
|
||||||
"fixture": fixture,
|
"fixture": {"db_path": fixture["db_path"]},
|
||||||
"chrome": chrome,
|
"chrome": chrome,
|
||||||
"streamlit": streamlit,
|
"streamlit": streamlit,
|
||||||
"pages": [],
|
"pages": [],
|
||||||
"server_command": server_cmd,
|
"server_command": server_cmd,
|
||||||
}
|
}
|
||||||
|
error: str | None = None
|
||||||
try:
|
try:
|
||||||
wait_for_http(base_url)
|
wait_for_http(base_url, ssl_context=ssl._create_unverified_context() if args.mode == "transition" else None)
|
||||||
for index, page in enumerate(args.pages or DEFAULT_PAGES):
|
if args.mode == "transition":
|
||||||
manifest["pages"].append(capture_page(chrome, base_url, page, output_dir, devtools_port=19222 + index))
|
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"])
|
||||||
manifest["success"] = True
|
source_paths = (
|
||||||
print(json.dumps(manifest, ensure_ascii=False, indent=2))
|
"dashboard/app.py",
|
||||||
return 0
|
"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:
|
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:
|
if not args.keep_server and server.poll() is None:
|
||||||
os.killpg(server.pid, signal.SIGTERM)
|
os.killpg(server.pid, signal.SIGTERM)
|
||||||
try:
|
try:
|
||||||
server.wait(timeout=10)
|
server.wait(timeout=10)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
os.killpg(server.pid, signal.SIGKILL)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
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")
|
||||||
|
SURFACE_STYLE = {
|
||||||
|
"backgroundImage": "linear-gradient(rgb(1, 2, 3), rgb(4, 5, 6))",
|
||||||
|
"backgroundColor": "rgb(255, 255, 255)",
|
||||||
|
"visibility": "visible",
|
||||||
|
"display": "block",
|
||||||
|
"opacity": "1",
|
||||||
|
"rendered": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def surface_records() -> dict[str, dict[str, object]]:
|
||||||
|
return {
|
||||||
|
"stAppViewContainer": dict(SURFACE_STYLE),
|
||||||
|
"stSidebar": dict(SURFACE_STYLE),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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, Any]:
|
||||||
|
return {
|
||||||
|
"mode": "transition",
|
||||||
|
"success": True,
|
||||||
|
"observations": {
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"at_ms": 0,
|
||||||
|
"selected_menu": "요약",
|
||||||
|
"body_text_hash": "hash",
|
||||||
|
"loading": False,
|
||||||
|
"prior_body_visible": True,
|
||||||
|
"surfaces": surface_records(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"at_ms": 100,
|
||||||
|
"selected_menu": "분류 규칙",
|
||||||
|
"body_text_hash": "hash2",
|
||||||
|
"loading": True,
|
||||||
|
"prior_body_visible": False,
|
||||||
|
"surfaces": surface_records(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"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)"},
|
||||||
|
"surfaces": {"pre": surface_records(), "post": surface_records()},
|
||||||
|
},
|
||||||
|
"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)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_transition_manifest_requires_surface_style_evidence_on_every_frame() -> None:
|
||||||
|
manifest = complete_manifest()
|
||||||
|
manifest["observations"]["frames"][1]["surfaces"].pop("stSidebar")
|
||||||
|
with pytest.raises(ValueError, match="surface"):
|
||||||
|
evidence.validate_transition_manifest(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_transition_manifest_requires_stable_surface_style_evidence() -> None:
|
||||||
|
manifest = complete_manifest()
|
||||||
|
manifest["observations"]["stable_shell"]["surfaces"]["post"]["stSidebar"]["opacity"] = "0"
|
||||||
|
with pytest.raises(ValueError, match="surface"):
|
||||||
|
evidence.validate_transition_manifest(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value"),
|
||||||
|
(
|
||||||
|
("backgroundImage", "none"),
|
||||||
|
("backgroundColor", ""),
|
||||||
|
("visibility", "hidden"),
|
||||||
|
("display", "none"),
|
||||||
|
("opacity", "0"),
|
||||||
|
("opacity", "NaN"),
|
||||||
|
("rendered", False),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_validate_transition_manifest_rejects_non_visible_surface_styles(field: str, value: object) -> None:
|
||||||
|
manifest = complete_manifest()
|
||||||
|
observations = manifest["observations"]
|
||||||
|
for frame in observations["frames"]:
|
||||||
|
frame["surfaces"]["stAppViewContainer"][field] = value
|
||||||
|
observations["stable_shell"]["surfaces"]["pre"]["stAppViewContainer"][field] = value
|
||||||
|
observations["stable_shell"]["surfaces"]["post"]["stAppViewContainer"][field] = value
|
||||||
|
with pytest.raises(ValueError, match="surface"):
|
||||||
|
evidence.validate_transition_manifest(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_transition_manifest_rejects_missing_stable_shell_markers() -> None:
|
||||||
|
manifest = complete_manifest()
|
||||||
|
manifest["observations"]["stable_shell"]["document_marker"] = {"pre": None, "post": None}
|
||||||
|
with pytest.raises(ValueError, match="shell"):
|
||||||
|
evidence.validate_transition_manifest(manifest)
|
||||||
@@ -469,7 +469,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jarca-ledger-mcp"
|
name = "jarca-ledger-mcp"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
|
|||||||
Reference in New Issue
Block a user