7 Commits
Author SHA1 Message Date
gitea-release-agent 5813dd4841 chore(release): prepare v0.1.4
Merge the independently reviewed v0.1.4 release preparation.
2026-10-06 01:20:20 +00:00
genichin 37e8d18da6 [verified] chore(release): prepare v0.1.4 2026-10-06 10:19:25 +09:00
genichin 9afea37424 Merge pull request 'fix: 메뉴 전환 시 대시보드 셸 유지 (#9)' (#10) from issue-9-dashboard-menu-transition into main
Reviewed-on: #10
Reviewed-by: gitea-spec-agent <gitea-spec-agent@mail.jsfamily.quest>
Reviewed-by: gitea-review-agent <gitea-review-agent@mail.jsfamily.quest>
2026-10-06 01:03:48 +00:00
gitea-impl-agent 1ebc53d152 docs(evidence): bind PR 10 rework verification 2026-10-05 08:45:31 +00:00
gitea-impl-agent f01dc182ec fix(dashboard): address PR review 56 2026-10-05 08:41:33 +00:00
gitea-impl-agent a02aceee41 docs(requirements): link dashboard transition PR 2026-10-05 00:51:45 +00:00
gitea-impl-agent 96e11812ce fix(dashboard): keep shell during menu transitions
Replace document navigation with native Streamlit state transitions, preserve dashboard contracts, and add regression plus authenticated browser evidence for Issue #9.
2026-10-05 00:49:28 +00:00
19 changed files with 3233 additions and 58 deletions
+14 -6
View File
@@ -12,7 +12,15 @@ if __package__ in (None, ""):
st.set_page_config(page_title="Jarca Ledger", page_icon="💰", layout="wide")
from dashboard.common import clear_cache, get_db_path, get_transfer_review_items, inject_app_css, render_sidebar_nav # noqa: F401
from dashboard.common import (
clear_cache,
get_db_path,
get_transfer_review_items, # noqa: F401
inject_app_css,
render_dashboard_body,
render_sidebar_nav,
validate_dashboard_page,
) # noqa: F401
from dashboard.authentication import require_dashboard_authentication
from dashboard.page_views.accounts import page_accounts
from dashboard.page_views.assets import page_assets
@@ -32,15 +40,15 @@ def main() -> None:
db_path = get_db_path()
inject_app_css()
pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]
query_page = st.query_params.get("page", "요약")
page = query_page if query_page in pages else "요약"
st.session_state["page"] = page
query_page = st.query_params.get("page")
page = validate_dashboard_page(query_page, pages)
st.session_state["dashboard_page"] = page
with st.sidebar:
st.markdown("## 💰 Jarca Ledger")
st.caption("가계부 · 분류 · Import 운영 콘솔")
st.markdown('<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):
clear_cache()
st.rerun()
@@ -56,7 +64,7 @@ def main() -> None:
"계좌": page_accounts,
"Import": page_imports,
}
dispatch[page](db_path)
render_dashboard_body(page, lambda: dispatch[page](db_path))
if __name__ == "__main__":
+42 -8
View File
@@ -4,7 +4,6 @@ import sqlite3
from datetime import datetime
from html import escape
from typing import Any
from urllib.parse import quote
import importlib
import inspect
from zoneinfo import ZoneInfo
@@ -314,6 +313,9 @@ def inject_app_css() -> None:
border-color: rgba(37, 99, 235, 0.48);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.10);
}
.st-key-dashboard_body:has(.st-key-dashboard_loading [data-testid="stAlert"]) .st-key-dashboard_content {
display: none;
}
.jarca-card {
border: 1px solid rgba(226, 232, 240, 0.86);
border-radius: 22px;
@@ -355,13 +357,45 @@ def render_section_label(icon: str, label: str) -> None:
st.markdown(f'<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:
items = []
for name in pages:
active = " active" if name == current_page else ""
href = f"?page={quote(name)}"
items.append(f'<a class="jarca-nav-item{active}" href="{href}" target="_self">{escape(name)}</a>')
st.markdown(f'<div class="jarca-nav-list">{"".join(items)}</div>', unsafe_allow_html=True)
def validate_dashboard_page(page: str | None, pages: list[str]) -> str:
return page if page in pages else "요약"
def _sync_dashboard_page_query() -> None:
st.query_params["page"] = st.session_state["dashboard_page"]
def render_sidebar_nav(pages: list[str], current_page: str) -> str:
if "dashboard_page" not in st.session_state:
st.session_state["dashboard_page"] = current_page
return st.radio(
"메뉴",
pages,
key="dashboard_page",
label_visibility="collapsed",
on_change=_sync_dashboard_page_query,
)
def render_dashboard_body(page: str, renderer) -> None:
body = st.empty()
body.empty()
try:
with body.container():
with st.container(key="dashboard_body"):
loading = st.empty()
with loading.container():
with st.container(key="dashboard_loading"):
st.info(f"{page} 불러오는 중…")
with st.container(key="dashboard_content"):
renderer()
loading.empty()
except Exception:
body.empty()
with body.container():
st.error("페이지를 불러오지 못했습니다. 다시 시도해 주세요.")
if st.button("다시 시도", key=f"retry_{page}"):
st.rerun()
def parse_optional_int(value: str, field_name: str) -> int | None:
+3 -3
View File
@@ -34,7 +34,7 @@ spec:
type: RuntimeDefault
initContainers:
- 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
command:
- python
@@ -62,7 +62,7 @@ spec:
mountPath: /tmp
containers:
- 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
env:
- name: JARCA_LEDGER_DB_PATH
@@ -104,7 +104,7 @@ spec:
- name: mcp-tmp
mountPath: /tmp
- 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
command:
- 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
+38
View File
@@ -221,3 +221,41 @@ Set-Cookie: __Host-jarca_dashboard_auth=; Max-Age=0; Secure; HttpOnly; SameSite=
- 실패 계약: wrong credential, invalid configuration, cross-origin, malformed cookie tests
- 경계 계약: expiry, rotation, oversized token, same-host different-port origin tests
- 구현·스키마·예시 정합성: `tests/test_dashboard_auth_server.py`, `tests/test_dashboard_authentication_app.py`, `tests/test_dashboard_persistent_authentication.py`, source/document redaction search
## Dashboard 메뉴 전환 계약 — REQ-002
### dashboard-menu-transition
- 책임: authenticated Dashboard에서 공통 shell/background를 유지하고 선택된 page body만 교체한다.
- 종류·프로토콜: Streamlit rerun, `st.radio` callback, query parameter
- 호출 대상·operation: `dashboard.app.main()` → `render_sidebar_nav()` → `render_dashboard_body()`
- 버전: 1.0
- 호출 주체: authenticated browser
- 인증 방식: 기존 `gate` 계약 선행; 메뉴는 인증을 우회하지 않음
- 필요 권한: valid Dashboard auth cookie
#### 상태와 입력
정확한 menu options/order는 `월정산`, `요약`, `자산/마감`, `거래내역`, `확인필요 큐`, `미분류`, `분류 규칙`, `카테고리`, `계좌`, `Import`이다. selected value는 `st.session_state["dashboard_page"]`에 둔다. 직접 query의 `page`가 known option이면 해당 page를, absent/unknown이면 `요약`을 사용한다.
#### 출력·불변조건
- callback은 `st.query_params["page"]`만 갱신하고 `month`, `account_id`, `filter` 등 sibling key를 보존한다.
- menu click은 document/path navigation이 아니며 stable `st.empty` body container 안에서 target renderer를 실행한다.
- 공통 sidebar, refresh control, CSS/background는 유지하고 body만 target content로 대체한다.
- 느린 target에는 `<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`까지를 따른다.
+23 -4
View File
@@ -17,6 +17,7 @@ dashboard/
├── auth_server.py
├── authentication.py
├── app.py
├── common.py
└── page_views/
```
@@ -25,9 +26,12 @@ dashboard/
| `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/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/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 — 인증 검증
```text
@@ -35,6 +39,8 @@ tests/
├── test_dashboard_authentication.py
├── test_dashboard_persistent_authentication.py
├── test_dashboard_auth_server.py
├── test_dashboard_menu_transition.py
├── test_streamlit_browser_evidence.py
└── test_dashboard_authentication_app.py
```
@@ -43,12 +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_persistent_authentication.py` | test | claims, TTL, tamper, rotation | pytest tests | no real secrets | source change paired with test | developer |
| `tests/test_dashboard_auth_server.py` | test | HTTP routes and cookie scope | pytest tests | synthetic test credentials | source change paired with test | developer |
| `tests/test_dashboard_menu_transition.py` | test | REQ-002 menu/query/body transition and cache-clear regression | pytest/AppTest | synthetic fixture DB; no real secrets | source change paired with test and browser evidence where required | developer |
| `tests/test_streamlit_browser_evidence.py` | test | transition evidence manifest, redaction, TLS fixture and fail-closed validation | pytest | generated synthetic values only; no captured credential values | evidence-harness change paired with test | developer |
| `tests/test_dashboard_authentication_app.py` | test | gate ordering and cleanup/UI | AppTest | fixture DB only | source change paired with test | developer |
### browser-evidence — 실제 전환 관찰
| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 |
|---|---|---|---|---|---|---|
| `scripts/streamlit_browser_evidence.py` | verification tool | synthetic DB와 runtime 생성 인증 설정으로 실제 HTTPS Dashboard 전환의 frame/DOM/document/shell 관찰을 수집하고 redacted manifest를 검증 | `--mode transition` | 운영 DB·credential 사용 금지; 성공 증거는 browser/server teardown 뒤에만 확정 | source review and focused test required | developer |
| `docs/evidence/issue-9-dashboard-menu-transition/` | evidence artifact | exact source hash에 결합한 authenticated browser frame, screenshot, runtime, validator 결과 | `manifest.json` | synthetic fixture only; credential/token/cookie 값 금지 | immutable after verification except complete regeneration | verification runner |
### deployment-docs — 운영 문서
```text
docs/requirements/req-001_dashboard-administrator-authentication.md
docs/requirements/req-002_dashboard-menu-transition.md
docs/evidence/issue-9-dashboard-menu-transition/
docs/interface-contract.md
docs/runtime-flow.md
README.md
@@ -58,6 +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-002_dashboard-menu-transition.md` | requirement | approved REQ-002 revision 1.0 | REQ-002 | canonical traceability | update in place | developer |
| `docs/interface-contract.md` | contract | HTTP/cookie/gate contracts | contract IDs | no implementation secrets | update with API changes | developer |
| `docs/runtime-flow.md` | design | runtime states/transitions | flow IDs | no secret values | update with flow changes | developer |
| `README.md` | operations | local/deployment constraints | operator commands | no secret values | operational review | developer |
@@ -71,15 +89,16 @@ deploy/k8s/README.md
| `dashboard.server` | `dashboard.app` | 허용 | app remains protected by gate | `docs/runtime-flow.md` |
| `dashboard.app` | `dashboard.authentication` | 허용 | gate precedes DB/page dispatch | `docs/interface-contract.md` |
| `dashboard.authentication` | `dashboard.page_views` | 금지 | auth module never imports financial pages | `docs/project-structure.md` |
| `dashboard.app` | `dashboard.common` | 허용 | menu/query/body transition helpers only after auth gate | `docs/interface-contract.md` |
| `dashboard.auth_server` | `jarca_ledger_mcp` DB | 금지 | login/cleanup/logout are DB-free | `docs/requirements/req-001_dashboard-administrator-authentication.md` |
## 변경 규칙
| 구분 | 규칙 |
|---|---|
| 새 모듈·파일 배치 | auth route/gate changes stay in `dashboard/`; no DB session module |
| 새 모듈·파일 배치 | auth route/gate changes stay in `dashboard/`; menu transition stays in existing `dashboard.app`/`dashboard.common`; no DB session module |
| 이름·패키지 | use existing `dashboard` package and snake_case modules |
| 소스·테스트 대응 | auth source behavior requires focused pytest/AppTest coverage |
| 소스·테스트 대응 | auth source behavior requires focused pytest/AppTest coverage; REQ-002 transition behavior requires focused pytest/AppTest targets and real-browser evidence where specified |
| 생성 산출물 | evidence may be redacted; never commit credentials, secrets, tokens, or cookie values |
## 검증
@@ -98,4 +117,4 @@ deploy/k8s/README.md
```
- 정적 분석 기대 결과: exit 0 with no diagnostics
- 문서와 실제 트리의 정합성: every listed source/test/document path exists; dependency table has no forbidden reverse edge
- 문서와 실제 트리의 정합성: every listed source/test/document path exists; dependency table has no forbidden reverse edge; REQ-002 symbols resolve to `dashboard/app.py`, `dashboard/common.py`, and named tests
@@ -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`
+31
View File
@@ -112,3 +112,34 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo
- 경계 흐름: TTL boundary, rotation, oversized token, same-host different-port origin
- 동시성 흐름: independent failed submissions and no application lock; ingress rate-limit configuration review
- 운영 진입점과 기대 결과: `uvicorn dashboard.server:app --host 0.0.0.0 --port 8501`; valid HTTPS reaches login or authenticated page, invalid cookie receives matching deletion before unauthenticated page
## 메뉴 전환 — REQ-002
인증 gate와 기존 `dashboard-01` page dispatch의 선행 순서는 변하지 않는다. valid cookie를 통과한 뒤에만 menu transition flow가 실행되며, auth 실패는 기존 cleanup/unauthenticated 흐름으로 종료된다.
### transition — authenticated body replacement
- 시작 이벤트: authenticated browser selects a value in `st.radio(key="dashboard_page")`
- 선행조건: `gate-02` success; current page/query resolved
- 실행 방식: Streamlit callback and rerun, no document/path navigation
- 트랜잭션·영속성 경계: no new persistence; existing financial SQLite rules unchanged
| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|---|---|---|---|---|---|---|---|
| `transition-01` | dashboard app/common | authenticated selected menu | `render_sidebar_nav` reads exact 10 options and selected state | session state `dashboard_page` | Streamlit radio callback | `transition-02` | `dashboard-fail` |
| `transition-02` | common callback | selected `dashboard_page` | write query `page` only; preserve sibling query keys | browser query state | Streamlit query params | `transition-03` | `transition-03` |
| `transition-03` | dashboard app | validated page | open stable `st.empty` body, show target loading, invoke target renderer | body placeholder only; existing DB reads/writes | page/gateway code | `transition-04` | `transition-error` |
| `transition-04` | dashboard app | renderer completed | remove target loading and leave target body; shell/background remain | body replacement only | none | `authenticated` | `dashboard-fail` |
| `transition-error` | dashboard app | renderer exception | clear partial body and show generic error/retry locally | body error state only | none | retry reruns target | `authenticated` with shell intact |
### transition invariants
- known direct `?page=` selects that page; missing/unknown selects `요약`.
- serialized rapid selections commit the latest selection only.
- menu transition never runs before auth and never changes cookie/logout/DB schema/MCP semantics.
- 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 -1
View File
@@ -1,3 +1,3 @@
"""Jarca Ledger MCP server."""
__version__ = "0.1.3"
__version__ = "0.1.4"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
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"
readme = "README.md"
requires-python = ">=3.11"
+319 -30
View File
@@ -14,10 +14,16 @@ import asyncio
import base64
import json
import os
import platform
import shutil
import signal
import subprocess
import sys
import time
import hashlib
import math
import secrets
import ssl
import urllib.parse
import urllib.request
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("--page", action="append", dest="pages", help="Page to capture. Repeatable. Defaults to core E2E evidence pages.")
parser.add_argument("--keep-server", action="store_true", help="Leave Streamlit running after capture for manual inspection.")
parser.add_argument("--mode", choices=("capture", "transition"), default="capture", help="Capture mode")
parser.add_argument("--cert-file", default="", help="TLS certificate for transition mode")
parser.add_argument("--key-file", default="", help="TLS private key for transition mode")
parser.add_argument("--from-page", default="요약")
parser.add_argument("--to-page", default="분류 규칙")
parser.add_argument("--interval-ms", type=int, default=100)
parser.add_argument("--frames", type=int, default=8)
return parser.parse_args()
@@ -145,12 +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)}
def wait_for_http(url: str, timeout_seconds: int = 45) -> None:
def wait_for_http(url: str, timeout_seconds: int = 45, ssl_context: ssl.SSLContext | None = None) -> None:
"""Wait for HTTP readiness, using verified TLS unless explicitly overridden."""
deadline = time.time() + timeout_seconds
last_error: str | None = None
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=2) as response:
with urllib.request.urlopen(url, timeout=2, context=ssl_context) as response:
if response.status == 200:
return
last_error = f"HTTP {response.status}"
@@ -195,6 +209,7 @@ class CdpClient:
def __init__(self, ws: websockets.ClientConnection) -> None:
self.ws = ws
self.next_id = 1
self.events: list[dict[str, Any]] = []
async def call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
command_id = self.next_id
@@ -207,6 +222,8 @@ class CdpClient:
if "error" in payload:
raise RuntimeError(f"CDP {method} failed: {payload['error']}")
return payload.get("result", {})
if payload.get("method"):
self.events.append(payload)
async def cdp_capture(ws_url: str, url: str, expected_text: str, screenshot: Path, snapshot: Path) -> dict[str, Any]:
@@ -295,10 +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:
args = parse_args()
chrome = require_executable(args.chrome, "Chrome")
streamlit = require_executable(args.streamlit, "Streamlit")
streamlit = None if args.mode == "transition" else require_executable(args.streamlit, "Streamlit")
run_id = datetime.now().strftime("run-%Y%m%d-%H%M%S")
output_dir = Path(args.output_dir) if args.output_dir else DEFAULT_OUTPUT_ROOT / run_id
output_dir.mkdir(parents=True, exist_ok=True)
@@ -308,56 +548,105 @@ def main() -> int:
env = os.environ.copy()
env["JARCA_LEDGER_DB_PATH"] = str(db_path.resolve())
env["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
base_url = f"http://{args.host}:{args.port}"
transition_secrets: tuple[str, ...] = ()
if args.mode == "transition":
if not args.cert_file or not args.key_file:
raise SystemExit("transition mode requires --cert-file and --key-file")
env["JARCA_DASHBOARD_ADMIN_ID"] = secrets.token_urlsafe(18)
env["JARCA_DASHBOARD_ADMIN_PASSWORD"] = secrets.token_urlsafe(32)
env["JARCA_DASHBOARD_SIGNING_SECRET"] = secrets.token_hex(32)
transition_secrets = (env["JARCA_DASHBOARD_ADMIN_ID"], env["JARCA_DASHBOARD_ADMIN_PASSWORD"], env["JARCA_DASHBOARD_SIGNING_SECRET"])
scheme = "https" if args.mode == "transition" else "http"
base_url = f"{scheme}://{args.host}:{args.port}"
if args.mode == "transition":
server_cmd = [sys.executable, "-m", "uvicorn", "dashboard.server:app", "--host", args.host, "--port", str(args.port), "--ssl-certfile", args.cert_file, "--ssl-keyfile", args.key_file]
else:
server_cmd = [
streamlit,
"run",
"dashboard/app.py",
"--server.address",
args.host,
"--server.port",
str(args.port),
"--server.headless",
"true",
"--browser.gatherUsageStats",
"false",
streamlit, "run", "dashboard/app.py", "--server.address", args.host,
"--server.port", str(args.port), "--server.headless", "true",
"--browser.gatherUsageStats", "false",
]
server = subprocess.Popen(server_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, start_new_session=True)
manifest: dict[str, Any] = {
"success": False,
"mode": args.mode,
"base_url": base_url,
"output_dir": str(output_dir.resolve()),
"fixture": fixture,
"fixture": {"db_path": fixture["db_path"]},
"chrome": chrome,
"streamlit": streamlit,
"pages": [],
"server_command": server_cmd,
}
error: str | None = None
try:
wait_for_http(base_url)
wait_for_http(base_url, ssl_context=ssl._create_unverified_context() if args.mode == "transition" else None)
if args.mode == "transition":
observations = transition_capture(chrome, base_url, args.from_page, args.to_page, output_dir, 19222, args.interval_ms, args.frames, env["JARCA_DASHBOARD_ADMIN_ID"], env["JARCA_DASHBOARD_ADMIN_PASSWORD"])
source_paths = (
"dashboard/app.py",
"dashboard/common.py",
"scripts/streamlit_browser_evidence.py",
)
manifest.update(
{
"observations": observations,
"runtime": {
"viewport": {"width": 1440, "height": 1400},
"interval_ms": args.interval_ms,
"frame_count": args.frames,
"host_architecture": platform.machine(),
"python_version": platform.python_version(),
"browser_user_agent": observations.get("browser_user_agent", ""),
},
"source_revision": current_git_revision(),
"source_revision_kind": "git-head-base",
"source_worktree_dirty": git_worktree_dirty(source_paths),
"source_files": source_file_digests(source_paths),
"evidence_command": ["scripts/streamlit_browser_evidence.py", *sys.argv[1:]],
"limitations": ["TLS uses caller-provided local certificate"],
}
)
else:
for index, page in enumerate(args.pages or DEFAULT_PAGES):
manifest["pages"].append(capture_page(chrome, base_url, page, output_dir, devtools_port=19222 + index))
manifest["success"] = True
print(json.dumps(manifest, ensure_ascii=False, indent=2))
return 0
except Exception as exc: # noqa: BLE001 - failure is recorded in redacted evidence
error = str(exc)
finally:
log_tail = ""
if server.stdout is not None:
try:
time.sleep(0.5)
if server.poll() is not None:
log_tail = server.stdout.read()[-4000:]
except Exception:
log_tail = ""
manifest["server_log_tail"] = log_tail
(output_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
if not args.keep_server and server.poll() is None:
os.killpg(server.pid, signal.SIGTERM)
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(server.pid, signal.SIGKILL)
server.wait(timeout=5)
returncode = server.poll()
teardown = "complete" if not args.keep_server and returncode is not None else "incomplete"
effective_exit = 0 if returncode in (0, -signal.SIGTERM) else returncode
manifest["server_log_tail"] = ""
manifest["command"] = {"exit_code": effective_exit, "teardown": teardown}
if error:
manifest["error"] = error
if args.mode == "transition" and error is None and teardown == "complete" and effective_exit == 0:
try:
manifest["success"] = True
validate_transition_manifest(manifest, transition_secrets)
except Exception as exc: # noqa: BLE001 - preserve failed manifest
manifest["success"] = False
manifest["error"] = str(exc)
elif args.mode == "capture" and error is None and (
(args.keep_server and returncode is None)
or (teardown == "complete" and effective_exit == 0)
):
manifest["success"] = True
safe_manifest = redact_secrets(manifest, transition_secrets)
manifest_path = output_dir / "manifest.json"
temporary_path = output_dir / ".manifest.json.tmp"
temporary_path.write_text(json.dumps(safe_manifest, ensure_ascii=False, indent=2), encoding="utf-8")
temporary_path.replace(manifest_path)
print(json.dumps(safe_manifest, ensure_ascii=False, indent=2))
return 0 if manifest.get("success") is True else 1
if __name__ == "__main__":
+219
View File
@@ -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))
+229
View File
@@ -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)
Generated
+1 -1
View File
@@ -469,7 +469,7 @@ wheels = [
[[package]]
name = "jarca-ledger-mcp"
version = "0.1.3"
version = "0.1.4"
source = { editable = "." }
dependencies = [
{ name = "mcp" },