2 Commits
11 changed files with 1908 additions and 848 deletions
@@ -1,31 +1,32 @@
# Issue #9 authenticated browser evidence
This directory binds the dashboard menu-transition acceptance evidence to the exact working-tree files listed in `manifest.json`.
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/style frames at 50 ms intervals
- **Sampling:** 35 DOM/computed-style frames at 50 ms intervals
- **Viewport:** 1280 × 720
- **Browser:** Headless Chrome 154 on Linux x86_64
- **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 eight manifest checks are true:
All twelve manifest checks are true:
- the HTML document marker stayed identical;
- the sidebar marker stayed identical;
- the page background stayed identical;
- no post-click full-document `load` event occurred;
- the target loading state was observed;
- the 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 `fixture=preserved` query key survived;
- the unrelated `source=evidence` query key survived;
- final URL, selected menu, target body, and cleared loading state agreed.
`browser-observations.json` contains every sampled frame, body hashes, URL state, history events, document/sidebar markers, computed background, runtime metadata, and explicit limitations. `final-state.jpg` is the final visible browser state; it contains no credential or secret.
`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
@@ -33,15 +34,14 @@ Run the exact validator recorded in `manifest.json`. The captured result was:
```text
exit code: 0
PASS source_hashes=5 artifact_hashes=2 checks=8/8 frames=35 full_document_loads=0
PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=35 full_document_loads=0 surfaces=2
```
The browser action was executed through `mcp__playwright__browser_run_code_unsafe`. That API reports success/error rather than a numeric process exit code; this boundary is explicit in the manifest instead of being represented as a fabricated exit status.
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 fixture-only external wrapper delayed the rules renderer by two seconds so the loading interval could be sampled. It did not alter repository source.
- A temporary HTTPS ingress adapter supplied trusted proxy scheme/Host metadata. Repository authentication and same-origin enforcement remained active.
- The tunnel URL is intentionally ephemeral and is retained in the frame record only for provenance.
- 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.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 75 KiB

@@ -1,23 +1,23 @@
{
"schema": "jarca.issue9.evidence-manifest.v1",
"schema": "jarca.issue9.evidence-manifest.v2",
"issue": "jsfamily/jarca-ledger-mcp#9",
"captured_at": "2026-10-04T16:18:18.832Z",
"captured_at": "2026-10-05T08:17:17.108Z",
"source": {
"base_revision": "ffd943fe65875e67148c28ba44bae32918e338c9",
"git_head_base": "a02aceee416c5770dfd1f35ac94367f3c4e0c8c8",
"working_tree": true,
"files_sha256": {
"dashboard/app.py": "4c7b3a6264b4fb4ae0e445ddc04c625b295443816b198aa5a60615ac44846ff2",
"dashboard/common.py": "9a0b9dbb4ca88f27757fc293173821c48a56a30bc23f16b0f24731f246411c5a",
"scripts/streamlit_browser_evidence.py": "766beefb0d861ea6b08561c3aaa0c6d8915c9166e4221725e27b5e26cea63798",
"scripts/streamlit_browser_evidence.py": "3cfb449ce1d6fae3abb795727d7ca1bc10dbb759bb811f121abd74fa42993dd7",
"tests/test_dashboard_menu_transition.py": "d23c35d88f6289b7c7e84a271df6ff3f6ff1f25a3a885aa696cc93db0b37035e",
"tests/test_streamlit_browser_evidence.py": "b922593f96da5af9a0b1fe33cacbe3abe139ef00554e7c535fe7cc8f24679b23"
"tests/test_streamlit_browser_evidence.py": "7f595ccf4009961225328486c19258bb0f7bc7dbfb584c60616860274112724a"
}
},
"execution": {
"browser_tool": "mcp__playwright__browser_run_code_unsafe",
"browser_tool": "mcp__playwright__browser_evaluate + mcp__playwright__browser_run_code_unsafe",
"tool_status": "success",
"exit_code": null,
"exit_code_note": "The Playwright MCP call is not a process and exposes success/error status rather than a numeric exit code.",
"exit_code_note": "Playwright MCP reports success/error instead of a numeric process exit code.",
"transition": "요약 -> 분류 규칙",
"sampling_interval_ms": 50,
"frame_count": 35,
@@ -26,7 +26,11 @@
"height": 720
},
"browser": "HeadlessChrome/154.0.0.0",
"browser_architecture": "Linux x86_64",
"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",
@@ -35,7 +39,11 @@
"checks": {
"document_identity_stable": true,
"sidebar_identity_stable": true,
"background_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,
@@ -43,19 +51,18 @@
"final_agreement": true
},
"artifacts_sha256": {
"browser-observations.json": "d5dcd18f074b0f3e44e1b46eb9cf685f028b0f229957d35dc2330bedca5f0cbb",
"final-state.jpg": "5a9c94598776a9ddaf4bb1aed1c758f6d1c489c320f7a81ecb3acd0cb702760d"
"browser-observations.json": "414b62efb2f40483aae4227896040c1b5d887b2c31333f63046e6f34aec2fe8f",
"final-state.jpg": "cc4e1c28463f033ab1c26d1dc27c9bb93c6fc5495321c0cef73fdb1098221234"
},
"validator": {
"command": "python3 -c \"import hashlib,json,pathlib; root=pathlib.Path('.'); m=json.loads((root/'docs/evidence/issue-9-dashboard-menu-transition/manifest.json').read_text()); assert all(hashlib.sha256((root/p).read_bytes()).hexdigest()==h for p,h in m['source']['files_sha256'].items()); evidence=root/'docs/evidence/issue-9-dashboard-menu-transition'; assert all(hashlib.sha256((evidence/p).read_bytes()).hexdigest()==h for p,h in m['artifacts_sha256'].items()); d=json.loads((evidence/'browser-observations.json').read_text()); assert all(d['checks'].values()) and not d['document_load_events']; assert any(f['loading'] and not f['previous_body_visible'] for f in d['frames']); print('PASS source_hashes=5 artifact_hashes=2 checks=8/8 frames=%d full_document_loads=0' % len(d['frames']))\"",
"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=8/8 frames=35 full_document_loads=0"
"output": "PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=35 full_document_loads=0 surfaces=2"
},
"limitations": [
"The browser run used only a generated synthetic SQLite fixture; no operating ledger data was used.",
"A fixture-only external module wrapper delayed the rules renderer by two seconds to expose the loading interval; repository source was not modified for the delay.",
"A temporary HTTPS ingress adapter normalized trusted proxy scheme and Host metadata; repository authentication and same-origin checks remained active.",
"The ephemeral tunnel URL in browser-observations.json is expired and is retained only as provenance.",
"Playwright same-document URL events are recorded separately from full document load events."
"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
+5 -1
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
@@ -54,38 +54,119 @@ Issue #9의 Dashboard 메뉴 전환 계약이다. REQ-001의 인증 경계, cook
메뉴는 정확히 다음 순서와 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하지 않는다.
@@ -101,6 +182,10 @@ refresh control은 기존 `clear_cache` callable 계약을 유지하고, 메뉴
### 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가 유지된다.
@@ -117,6 +202,10 @@ refresh control은 기존 `clear_cache` callable 계약을 유지하고, 메뉴
### 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를 선택한다.
@@ -133,6 +222,10 @@ callback은 `page`만 갱신하고 sibling key를 보존하며, missing/unknown
### 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은 판정하지 않는다.
@@ -149,6 +242,10 @@ Required real-browser evidence: `docs/evidence/issue-9-dashboard-menu-transition
### 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가 제거된다.
@@ -162,10 +259,14 @@ slow renderer 동안 target label loading이 먼저 표시되고 완료 후 plac
- 수준: unit + browser evidence
- 백엔드: pytest fake Streamlit placeholder, authenticated Playwright browser
- 증거: ordered event list와 50 ms 연속 frame에서 `loading=true`, `previous_body_visible=false`, 최종 `loading=false`
- 한계: browser evidence의 2초 지연은 repository source가 아닌 fixture-only wrapper로 주입한다.
- 한계: 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로 국소 대체되고 예외 상세가 노출되지 않는다.
@@ -182,6 +283,10 @@ renderer 예외가 generic error/retry body로 국소 대체되고 예외 상세
### REQ-002.06-AC01 — latest selection
**Cases**: concurrency, unchanged-state
**State Invariants**: latest serialized selection is final.
**판정 기준**
연속 선택 `거래내역` → `자산/마감` → `Import` 후 query가 마지막 `Import`이고 sibling key가 보존된다.
@@ -198,6 +303,10 @@ renderer 예외가 generic error/retry body로 국소 대체되고 예외 상세
### 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 전에 종료된다.
@@ -214,6 +323,10 @@ valid cookie가 기존 Dashboard를 열고 cookie가 session state에 저장되
### 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 결과가 유지된다.
@@ -230,6 +343,10 @@ valid cookie가 기존 Dashboard를 열고 cookie가 session state에 저장되
### 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` 계약을 유지한다.
@@ -244,15 +361,67 @@ refresh control이 callable `clear_cache` 계약을 유지한다.
- 백엔드: 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. 추적성
## 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`
- 관련 요구사항: `REQ-001`
- 관련 설계 문서: `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`
+1
View File
@@ -137,6 +137,7 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo
- 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
+43 -5
View File
@@ -21,6 +21,7 @@ import subprocess
import sys
import time
import hashlib
import math
import secrets
import ssl
import urllib.parse
@@ -326,6 +327,26 @@ def redact_secrets(value: Any, secrets_to_redact: tuple[str, ...] = ()) -> Any:
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)
@@ -337,14 +358,30 @@ def validate_transition_manifest(manifest: dict[str, Any], secrets_to_redact: tu
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 {}
if not all(isinstance(shell.get(name), dict) for name in ("document_marker", "sidebar_marker", "background")):
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(values.get("pre") != values.get("post") for values in (shell["document_marker"], shell["sidebar_marker"], shell["background"])):
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]
@@ -444,6 +481,7 @@ async def cdp_transition(ws_url: str, base_url: str, from_page: str, to_page: st
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)};
@@ -456,7 +494,7 @@ async def cdp_transition(ws_url: str, base_url: str, from_page: str, to_page: st
deadline = time.time() + 45
index = 0
while time.time() < deadline and (index < frame_count or not observations or observations[-1].get("selected_menu") != to_page):
expression = """(() => { const body=document.body; const text=body?.innerText||''; const main=document.querySelector('[data-testid=stMain]')||body; const mainText=main.innerText||''; const sidebar=document.querySelector('[data-testid=stSidebar]'); const selectedInput=document.querySelector('[data-testid=stRadio] input:checked'); const selected=selectedInput?.closest('label')?.innerText?.trim() || document.querySelector('[aria-current=page]')?.textContent?.trim() || ''; const prior=window.__evidencePriorBodyText||''; return {at_ms:%d, selected_menu:selected, body_text:mainText, prior_body_visible:Boolean(prior)&&mainText.includes(prior), document_marker:window.__evidenceDocumentMarker||'', sidebar_marker:sidebar?.dataset.evidenceMarker||'', background:getComputedStyle(document.body).backgroundColor, loading:/불러오는 중/.test(text)}; })()""" % (index * interval_ms)
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()
@@ -466,7 +504,7 @@ async def cdp_transition(ws_url: str, base_url: str, from_page: str, to_page: st
await cdp.call("Runtime.evaluate", {"expression": click, "returnByValue": True})
index += 1
await asyncio.sleep(interval_ms / 1000)
final = await cdp.call("Runtime.evaluate", {"expression": "(() => { const main=document.querySelector('[data-testid=stMain]')||document.body; const sidebar=document.querySelector('[data-testid=stSidebar]'); const selectedInput=document.querySelector('[data-testid=stRadio] input:checked'); return {url:location.href, body:main.innerText||'', selected:selectedInput?.closest('label')?.innerText?.trim() || document.querySelector('[aria-current=page]')?.textContent?.trim() || '', document_marker:window.__evidenceDocumentMarker||'', sidebar_marker:sidebar?.dataset.evidenceMarker||'', background:getComputedStyle(document.body).backgroundColor, history_events:window.__evidenceEvents||[], browser_user_agent:navigator.userAgent}; })()", "returnByValue": True})
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", "")
@@ -476,7 +514,7 @@ async def cdp_transition(ws_url: str, base_url: str, from_page: str, to_page: st
document_events = [{"type": event.get("method"), "params": event.get("params", {})} for event in cdp.events if event.get("method") in ("Page.frameNavigated", "Page.loadEventFired")]
pre = observations[0] if observations else {}
loading_frames = [frame for frame in observations if frame.get("loading")]
return {"frames": observations, "document_navigation_events": document_events, "history_events": data.get("history_events", []), "stable_shell": {"document_marker": {"pre": pre.get("document_marker"), "post": data.get("document_marker")}, "sidebar_marker": {"pre": pre.get("sidebar_marker"), "post": data.get("sidebar_marker")}, "background": {"pre": pre.get("background"), "post": data.get("background")}}, "loading_observed": bool(loading_frames), "prior_body_removed": bool(loading_frames) and all(frame.get("prior_body_visible") is False for frame in loading_frames), "target_page": to_page, "final_agreement": data.get("selected") == to_page and to_page in data.get("body", ""), "final_url": data.get("url", ""), "browser_user_agent": data.get("browser_user_agent", "")}
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,
+64 -1
View File
@@ -4,6 +4,7 @@ import json
import ssl
import sys
from pathlib import Path
from typing import Any
from unittest.mock import patch
@@ -14,6 +15,21 @@ 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:
@@ -39,7 +55,7 @@ def test_validate_transition_manifest_requires_observations() -> None:
evidence.validate_transition_manifest(manifest)
def complete_manifest() -> dict[str, object]:
def complete_manifest() -> dict[str, Any]:
return {
"mode": "transition",
"success": True,
@@ -51,6 +67,7 @@ def complete_manifest() -> dict[str, object]:
"body_text_hash": "hash",
"loading": False,
"prior_body_visible": True,
"surfaces": surface_records(),
},
{
"at_ms": 100,
@@ -58,6 +75,7 @@ def complete_manifest() -> dict[str, object]:
"body_text_hash": "hash2",
"loading": True,
"prior_body_visible": False,
"surfaces": surface_records(),
},
],
"document_navigation_events": [],
@@ -66,6 +84,7 @@ def complete_manifest() -> dict[str, object]:
"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,
@@ -164,3 +183,47 @@ 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)