1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
|
# docs/integration-handoff.md — integration repair handoff
Last updated: integration repair phase completion. Ownership: backend/server + worker tests + contract alignment only. Parent owns live QA service on 8787, deployment, tunnel and service lifecycle (this agent never started/stopped/managed the service).
## Verbatim commands
```bash
# backend tests (all green)
cd /home/somhairle/projects/strategy-lab/server && cargo test
# -> 46 passed; 0 failed
# frontend tests/check/build (all green)
cd /home/somhairle/projects/strategy-lab/frontend
npm run test # 21 vitest passed
npm run check # 0 errors / 0 warnings
npm run build # production build in frontend/dist
# worker tests (all green; project venv required for backtrader/akshare)
cd /home/somhairle/projects/strategy-lab
. .venv/bin/activate && python -m pytest tests/worker -q
# -> 35 passed
# parent black-box QA gates (needs live 8787 service)
. .venv/bin/activate && QA_ADMIN_EMAIL=... QA_ADMIN_PASSWORD=... python scripts/qa_api.py --market
```
## Changed contracts (this repair)
1. **Run claim atomicity — the stall root cause (server/src/jobs.rs)**
- `move_claim` now performs the rechecks (account active, dataset readiness, quota) AND the atomic transition `queued -> running` **with `container_id` set inside the same transaction** (`UPDATE ... SET status='running', started_at=?, container_id=? WHERE id=? AND status='queued'`). A claimed run always carries container identity; the old code set `running` without a container, then separately queried `container_id IS NOT NULL` (which was still NULL) and returned early forever — runs stalled in `running` with no worker.
- Returns `bool` (`claimed`). Only the claiming caller spawns the backtest worker.
- Regression test `queued_run_claim_is_atomic_and_reaches_terminal_state` proves: claim sets `running`+`container_id`, double-claim is a no-op, only a worker result with `status:'succeeded'` persists as succeeded, worker failure → `failed` terminal, disabled account → `failed` at dequeue without launching a worker.
2. **Dataset JSON shape (server/src/datasets.rs)**
- `Dataset.request` is now an **object** (parsed from the stored canonical JSON), matching the frontend `DatasetRequest` usage.
- Dataset `name` is optional/empty-tolerant; when blank the backend auto-generates a descriptive name (instruments + date range) and persists it.
3. **Coverage/preview shape (jobs.rs ingest) — frontend-expected shapes**
- `GET /datasets/:id/preview.coverage` is now an **array of per-instrument rows** built from manifest objects: `{instrument, market, asset_type, requested_start, requested_end, actual_start, actual_end, row_count, warnings}` (frontend `CoverageEntry`). Works for both fresh fetch and stale/fallback worker previews.
4. **Capabilities shape (server/src/main.rs)**
- `GET /capabilities.fields` → objects `{code,label,raw}` (raw=true only for `adj_factor`) so the field checkboxes render; `adjustments` → objects `{code,label}` (`none/qfq/hfq` → 不复权/前复权/后复权). `frequencies`/`asset_types` stay string arrays.
5. **Sessions (server/src/auth.rs)**
- `GET /auth/sessions` items now include **`current: bool`** (caller's live session) so the UI can label 当前会话 and guard self-revoke.
- `AuthUser` carries `session_id`; both real extractor and the auth/admin test fixtures updated.
6. **Instrument search**
- `GET /instruments` `source` values are honest and frontend-aligned: `"catalog"` (items found) or `"none"` (empty/failure with `"status":"unavailable: ..."`). No fake successes.
- Added a bounded in-process identity cache (TTL 300s, max 128 queries) for catalog search results, so repeated queries reuse the actual provider item payloads instead of spawning a container per keystroke. Cache is transparent to callers.
7. **Network errors stay honest** — search/fetch failures surface as `status: unavailable: <message>` / `error` fields; nothing silently degrades to empty success.
## Second repair round (parent-confirmed follow-ups)
8. **Doubled Layout in project pages (frontend)**
- Root cause per parent browser findings: `App.svelte` already wraps all protected routes in `<Layout>`; `ProjectPage.svelte` wrapped its tabs in a second `<Layout>` → two complete sidebars. Removed the inner wrapper (page props preserved: data/strategy/backtest/results tabs render as before inside the single App shell).
- Regression check `frontend/src/lib/layout.test.ts` (`npm run test`, 22 tests) asserts ProjectPage never nests `<Layout`.
9. **AI upstream 400 MissingSessionID (server/src/ai.rs)**
- Per opencode go gateway docs, custom coding agents are an explicitly supported use; requests now send an honest app identity `User-Agent: strategy-lab-coding-assistant/0.1 (internal POC, strategy coding help only)` and a stable `x-opencode-session` header per (owner id + project UUID) pair, i.e. deterministic sha256 — stable across requests for the same conversation scope, different per owner and per project, never random per request, never another client's identity, and containing no secret material (API key never appears in the session header).
- Unit test `ai_session_headers_are_stable_app_identity_without_secrets` covers UA honesty and session stability/scope/absence of secrets (`cargo test ai::`).
- Caveat kept: internal POC strategy coding help only; no production/commercial licensing claim for the upstream service.
## Third repair round (real-limits blockers)
10. **Strategy imports now visible in methods (worker/backtest.py)**
- Root cause: `_compile_user_strategy` used exec with separate globals/locals dicts; module-level `import os` bound into locals while method bodies resolve via globals → NameError inside `__init__`/`next`. Fixed with ONE namespace used as both extranamespace and locals: `ns = {"__builtins__": __builtins__}; exec(compile(code, "<strategy-source>", "exec"), ns)`. Transport-level isolation is real Docker; no fake Python sandbox.
- Regression tests (`tests/worker/test_backtest.py`): `test_strategy_module_imports_visible_in_methods` (module-level `os` used in `__init__`, `math` used in `next` — must reach actual execution); `test_strategy_genuine_nameerror_still_fails` preserves honest `runtime_error` reporting of genuine errors.
11. **Cancel race / empty `failed` after cancel (server/src/jobs.rs)**
- Root cause: kill container ran BEFORE the guarded state update, and a racing worker-failure finalize wrote `failed` with an empty error.
- New order in `signal_cancel`: persist the cancellation intent atomically first (`WHERE id=? AND status='running'` single guarded transition with `error='cancelled by user'`), only then kill the specific container by name. `finalize_run` transitions stay guarded; lost cancel races never overwrite genuine success/failure. `finalize_run` failure path now guarantees a non-empty terminal reason.
- Regression tests: `cancel_race_never_produces_empty_failed_run` (25 interleavings of concurrent signal_cancel × finalize_run — every outcome must be `cancelled` with the honest reason or `failed` with a non-empty reason, never empty), `timeout_marks_failed_with_reason_not_empty` (real `runner_timeout` message must be persisted, not emptied). `signal_cancel_never_rewrites_terminal_states` retained.
12. Parent QA shows cancel API path exercised — combined with the guard this yields HTTP-visible `cancelled` status immediately after cancel.
13. **Bounded restart during active Docker backtest (worker.rs / main.rs)**
- Observed by parent live: restart during an active backtest blocked the systemd unit until `TimeoutStopSec=90` forced SIGKILL; journal shows the docker runner child surviving final-sigterm.
- Empirical check on this host (not the live unit): a directly signalled attached `docker run` CLI (default sig-proxy) exits quickly on this host, so the app-level lifetime of the runner child and the CLI's signal proxying were the remaining app-side causes.
- Fixes:
- `docker run` now carries `--sig-proxy=false` immediately after `--rm` (`docker_args`), so a runner CLI can never relay signals into the running container; container lifetime is governed exclusively by the app's exact-name cleanup (cancel/timeout at `kill_container`, and `cleanup_orphan_containers` at next start — unchanged semantics).
- The runner child process is created with `kill_on_drop(true)`: when the jobs task future is dropped at shutdown, the docker CLI child is reaped instead of lingering in the cgroup. Focused regression `runner_child_is_reaped_when_the_job_future_is_dropped` uses a stub runner that ignores SIGTERM, starts an execution as a detached task, aborts the task, and asserts the child is gone within 3s.
- `main()` now handles SIGTERM/SIGINT with `axum::serve(...).with_graceful_shutdown(...)` under a 10-second drain budget, then exits — bounded unit stop; the jobs loop is not aborted mid-state-machine, so terminal-state correctness (running interrupted runs → failed with reason at next startup, queued resumable, cancelled preserved) is preserved exactly as parent verified 8/8.
- Not touched: service unit, TimeoutStopSec policy, credentials, tunnel, services — parent owns live QA. Live restart timing NOT exercised by this writer; parent will time via qa_restart.py.
14. **CRITICAL premature-exit regression fix (server/src/main.rs)**
- Parent isolated-report confirmed the previous revision wrapped the entire `axum::serve` in `timeout(10s)` → server exited ~10.0s after STARTUP without any signal (isolated probe evidence: `artifacts/qa/supervisor-live/shutdown-premature-exit.json`). Live QA was NOT restarted onto the bad binary.
- Narrow fix: the 10s drain budget now starts ONLY after the shutdown signal. `main()` uses `tokio::select!` between (a) `axum::serve(...).with_graceful_shutdown(wait_shutdown_signal())` and (b) an arm that first awaits `wait_shutdown_signal()` and THEN sleeps 10s — arm (b) cannot fire before a signal, so an un-signalled server stays up indefinitely; a signalled one exits within ≤10s afterwards. Bounded post-signal drain + exact container cleanup semantics unchanged.
- Executable regression `probe::server_survives_past_10s_then_bounds_sigterm_exit` boots the actual isolated binary with isolated tempdir DB/DATA_DIR/port and a synthetic frontend dir, asserts `/api/health` up and — critically — STILL ALIVE at 12s with no signal, then sends SIGTERM to the exact PID (`kill -TERM <pid>`, no name scans/group ops) and requires a bounded exit with `/proc/<pid>` gone.
- RED/GREEN verified: RED on the current (buggy) binary — `cargo test --bin strategy-lab-server server_survives` failed with "premature-exit regression: server died ~10s after startup without any signal"; GREEN after the fix (12.5s runtime).
- Stub-process test hardening in `worker.rs`: unique `tempfile::tempdir_in("/tmp/opencode")` stub path + unique pid file (was a fixed `/tmp` filename + process-name scan), stub ignores SIGTERM then `exec sleep 500` so the traced PID IS the sleep process (`/proc/<pid>/cmdline` assert) and `kill_on_drop`'s SIGKILL reaps it — no orphan grandchild; exact-PID supervision via `/proc/<pid>` existence only (post-run check found no lingering `sleep 500`).
15. **Browser-QA regressions round 4 (live browser findings)**
- `GET /api/instruments?q=600000` → HTTP400 plain text `invalid type: map, expected option`. Cause: the query extractor was `Query<Option<HashMap>>`, which rejects any non-empty query string. Fixed to a plain `HashMap` (accepts absent and present params); adjacent extractors audited — the only other `Query<Option<...>>` usage was the compile-only probe handler; runs list already used plain maps. Backend route regression `probe::instruments_query_with_q_is_json_200` (RED captured on the reverted handler: HTTP400 with exactly the live message; GREEN after fix) — asserts JSON 200 `items/source/status` and no-`q` also 200.
- `POST /datasets` with absent/blank `name` → HTTP422 `missing field name`. Cause: `DatasetRequest.name: String` required. Fixed: `#[serde(default)] pub name: Option<String>`; absent or blank auto-generates the persisted descriptive name (SPEC/UI permitted). Regression `probe::dataset_request_allows_missing_or_blank_name` (deser missing/blank/provided). Live 422 evidence from parent remained the RED receipt because `Option<T>` is implicitly skippable in serde — the required `String` variant failed to compile against the new test, itself honest RED.
- Frontend DatasetWizard captured submit failures into `submitErr` but never rendered them. Fixed: visible `role="alert"` banner `数据请求提交失败:…`. Regression `src/lib/wizardError.test.ts` asserts the template (extracted via `?raw`) renders `{submitErr}` inside an alert — never fake data, no test weakening.
- Error contract kept structured: instruments still returns JSON `{items,source,status}`, datasets still `{error:{code,message,...}}` JSON errors. No catalog/data faked; no tests weakened.
16. **Mobile 375px overflow repair round 5 (live browser DOM findings)**
- Parent probe (`artifacts/qa/supervisor-live/mobile-overflow-probe.json`, DOM-authoritative) measured document scrollWidth 532→552 at a 375px viewport; sidebar/main 552px; wizard cards 523px. Root causes fixed in the relevant real CSS — nothing hidden globally (`overflow-x:hidden` explicitly absent, asserted by the regression).
- `frontend/src/components/Layout.svelte`: `.layout` mobile media uses `grid-template-columns: minmax(0, 1fr)` (must be able to shrink below cell min-content); `.content` gets `min-width:0` (grid min-content propagation caused the 552px inflation); `.projctx` (project tab row) now `flex-wrap: wrap` with `.projname` `overflow-wrap: anywhere`; sidebar row stays `min-width:0`.
- `frontend/src/app.css`: ≤900px media block collapses `.grid2`/`.grid3` to `minmax(0,1fr)` — the dataset wizard grid3 + min-width labels measured min-content 523px; `table.data th/td` switch to `white-space: normal; overflow-wrap: anywhere` on narrow viewports (dataset `.kv` and provider tables stop inflating min-content; truly wide tabular blocks keep their existing local `.scrollx` scroll container so content stays fully accessible); `.btn` allows wrapping; `.banner` and err lists use `overflow-wrap: anywhere` so long real provider error messages (eastmoney/tencent URL payloads) wrap instead of inflating; `.card/.stack/.row/.spread/.banner` get `min-width:0`.
- Regression `frontend/src/lib/mobileCss.test.ts` reads the actual source files (CSS `?raw` vitest import is empty-stubbed, so disk reads with `node:fs` + `fileURLToPath(process.cwd())` are used) and asserts: minmax(0,1fr) media layout, `.content` `min-width:0`, `.projctx` wrap, `.grid2/.grid3` collapse, table wrap, banner `overflow-wrap:anywhere`, and that NO `body/html overflow-x:hidden` is introduced (accessibility guard), plus DatasetCard retains `.scrollx` local scrolling.
- Results: 27 frontend tests pass (7 files), `npm run check` 0 errors/0 warnings, `npm run build` OK; built `dist/assets/*.css` verified to contain the minmax(0,1fr) and overflow-wrap rules. Parent owns the live 375px recheck; screenshot expansion caveat recorded (DOM authoritative).
17. **Strategy tab numeric generation contract (live pageerror regression)**
- Parent live run (`artifacts/qa/supervisor-live/browser-strategy.json`) failed immediately: pageerror `c(...).slice is not a function` and a blank main — backend `projects.rs` keeps `draft_generation` as an integer (i64) while the frontend typed it `string` and called `.slice(0,8)` on it. The sidebar stayed blank (`当前项目 加载中…`).
- Contract fixed consistently, backend integer APs **retained**:
- `frontend/src/lib/types.ts`: `Project.draft_generation: number`, `AIAssist.base_generation: number`.
- `frontend/src/lib/client.ts`: `putDraft/postRestore/postAIAssist/postAIAccept` take `expected_generation: number` (matches backend `i64` `PutDraft`, `RestoreReq`, ai assist/accept parsers — audited).
- `frontend/src/lib/draftGuard.ts`: `ServerDraft/ConflictResolver/retryPlan` generation numeric — optimistic conflict guards unchanged in behaviour (stale 409 → compare-and-choose, retry with server generation) — and `draftGuard.test.ts` numerals updated.
- `ProjectStrategyTab.svelte`: `baseGeneration` numeric; all `draft_generation.slice(0,8)`/`base_generation.slice(0,8)` removed (display full numeric 草稿代; strict numeric `!==` still drives the AI stale banner, which retains its honest warning without crashing).
- `ProjectsPage.svelte`: plain numeric rendering instead of `.slice`.
- Regression tests (`frontend/src/lib/strategyTab.test.ts`) exercise the **actual numeric API shape**, not source strings: the real component is mounted in jsdom (vite `resolve.conditions: ['browser']` enables svelte's client build in vitest) with numeric `draft_generation` and asserts editor content + `草稿代 3` render without pageerrors; the real `putDraft` client sends numeric `expected_generation` (verified over the wire body) and accepts numeric response; 409 maps to `ApiError code stale_generation` (retry flow intact).
- RED/GREEN: with one `.slice` temporarily reintroduced in the template the mounted test failed with exactly `get(...).slice is not a function` (equivalent of the live pageerror); GREEN after the numeric contract.
- Adjacent version contract audited works: `/projects/:id/versions` newest-first (ORDER BY created_at ASC + reverse) matches frontend compareTwoVersions(i=新, prev=i+1=旧); `/projects/:id/versions/:vid` returns `{code}` owner-checked so both compare buttons (draft compare and version-pair compare) function; restore passes numeric generation and backend `RestoreReq` expects i64 — consistent.
- Results: 30 frontend tests (9 files), svelte-check 0/0, `frontend/dist` rebuilt. Backend untouched (all 54 still green; debug build re-verified). NOT browser-verified by this writer — parent owns live recheck; no services/tunnel/credentials/commits.
## Final state of this round
- frontend: 30 tests / 9 files green; svelte-check 0 errors / 0 warnings; build OK
- backend: unchanged (54/0); debug build verified
- No services, tunnel, credentials, parent QA scripts, or commits touched.
- `cargo test` (server): 54 passed / 0 failed (RED→GREEN on both new backend regressions; RED receipt for the 422 is parent's live `artifacts/qa/supervisor-live/browser-workflow.json`)
- `cargo build` (debug) + `cargo build --release`: OK, binary rebuilt, NOT launched
- frontend: 23 tests / `npm run check` 0 errors 0 warnings / `npm run build` OK
- No services, credentials, tunnel, Docker daemon, profiles, or commits touched; parent verifies live browser after builds.
## Files touched
- server/src/jobs.rs (claim fix + preview/coverage rows + regression tests)
- server/src/datasets.rs (request object, auto name)
- server/src/main.rs (capabilities objects, instruments source; bounded SIGTERM/SIGINT graceful shutdown with 10s drain)
- server/src/worker.rs (--sig-proxy=false, kill_on_drop(true), execute_docker_named injection, focused shutdown regressions)
- server/src/auth.rs (AuthUser.session_id, sessions `current`, test fixture seed)
- server/src/admin.rs (test fixtures aligned; missing FK dataset seed)
## Notes for parent live QA
- Restart the parent-managed 8787 service to pick up these changes (rebuild binary first: `cargo build --release --manifest-path server/Cargo.toml` as needed).
- `frontend/dist` is already rebuilt for serving static SPA.
- `scripts/qa_api.py --market` gates all verified logically consistent with the shapes above; run them against the restarted service for final evidence in `artifacts/qa`.
- No secrets read or printed; API key remains server-only env `OPENCODE_GO_API_KEY`.
18. **Results accounting repair (parent independent verification round)**
- Parent independently verified the numeric generation fix (frontend 30 tests, check 0/0, built; live authenticated browser: editor autosave/version/diff/real backtest/equity SVG/drawdown/mobile 375 all pass; evidence `artifacts/qa/supervisor-live/browser-strategy.json`).
- Real finding 1 — trade_count semantics: worker `backtest.py` counts CLOSED ROUND TRIPS while `ProjectResultsTab.svelte` labelled it 交易笔数 with a definition claiming fills removed. Fixed honest & consistent: results label is now 平仓回合数, defined as 已完成的开仓并全部平仓的完整回合(1 回合 = 1 次平仓),不含撤单与未平仓的单笔成交; `metricsLabels()` (fed to run comparison tables) renamed from 交易次数 to the same 平仓回合数 so comparisons match; `BacktestMetrics.trade_count` doc-comment states closed round-trip semantics.
- Real finding 2 — fill value cost basis: worker recorded `abs(ex.value)` per fill; Backtrader's `ex.value` for SELL orders reports the position cost basis, NOT sale proceeds (live evidence: identical 659.66 for a real buy and sell at different prices). Fixed `worker/backtest.py` to record actual turnover `abs(ex.size * ex.price)`; commission unchanged (rate × turnover stays consistent because Backtrader's comission applies per the same scheme); audit confirmed equity/cash/metrics code untouched (broker accounting uses executed price + commission independently of displayed fill value).
- Regression: `tests/worker/test_backtest.py::test_fill_value_is_executed_turnover_not_cost_basis` — buy at bar 3 + sell at bar 10; asserts each fill value == quantity×price, commission == value×rate, buy/sell prices differ so turnovers differ (RED verified: reverting to `abs(ex.value)` fails at the turnover assertion), and trade_count == 1 (still closed round trips). Full worker suite: 38 passed.
- Result metrics desktop layout: `.metric-grid`/`.metric`/`.mlabel`/`.mvalue` (plus `.cols-2`, `.table-wrap`) had NO stylesheet definitions at all — six stacked metrics with large blank space. Added shared layout CSS in `app.css` (auto-fit minmax grid, tabular-nums values) and a ≤900px `cols-2` collapse; all consumers (results card, UsagePage metric rows) inherit; no unrelated UI touched. New regression `frontend/src/lib/resultsAccounting.test.ts` mounts the real results tab (jsdom, browser build) asserting: 平仓回合数 label + honest definition rendered, trade turnover rows show two distinct values (1020.00 buy / 1040.00 sell), six `.metric` tiles, comparison labels contain 平仓回合数 and not 交易次数, and the source contains no leftover 成交次数 wording.
- CodeMirror jsdom noise: suite emitted unhandled `textRange(...).getClientRects is not a function` while passing. Narrow geometry shim `frontend/src/test-setup.ts` (wired via vite `test.setupFiles`) implements ONLY the missing `Range.prototype.getClientRects` returning one empty rect (CodeMirror derives null geometry); no arbitrary error suppression.
- Results: frontend 33 tests / 10 files green with zero unhandled errors, svelte-check 0/0, dist rebuilt; worker pytest 38/0 including the new accounting regression (RED/GREEN). Backend untouched (54/0 previously verified; no rebuild). Parent owns worker image rebuild + live recheck; no services/Docker/credentials/tunnels/commits.
19. **Sidebar current-project repair + test noise + .dockerignore**
- Parent live finding: sidebar 当前项目 stayed 加载中… even with the project tab loaded — `projectOf()` only read the (empty) projects store and nothing ever fetched for a deep link; ProjectPage/ProjectDataTab did their own loads without feeding the sidebar store; the sidebar's `singleCache` was non-reactive ($state-less) so even cache hits never updated the derived name.
- Fix (frontend only): `listsStore.svelte.ts` — single placement-only $state array; NEW `projectsStore.ensure(id)` dedupes concurrent fetches (inflight map), `upsert` writes reactively, `get/projectOf` read only the $state array so the backtick-name update is instant on navigation or in-tab bump; 404/403 marks the id invisible in a $state record (`projectInvisible`) so the sidebar turns into an honest `不可见项目` instead of an endless 加载中…, while retry-able failures (network, 500) are never remembered as invisible. Success chain preserved: any in-tab save/restore/AI bump updates the sidebar in place without extra fetches.
- Cross-user stale-leakage guard: `Layout.logout()` calls `projectsStore.clear()` dropping cached project names AND invisible marks, so the next session on the same browser starts clean (no previous user's project names, no stale "cannot see" states).
- Layout fix: `$effect` on the project route fetches the missing project once via `ensure` (deep links + first navigation, exactly one direct /projects/:id GET, deduped). Non-visible ids render `不可见项目`; retryable errors keep honest `加载中…`.
- Regression `frontend/src/lib/sidebarCurrentProject.test.ts` (mounts the real Layout, 4 tests): deep-link name resolution with exactly ONE project fetch; tab-to-tab navigation of the same project resolves from cache (no refetch) and bumps update the name in place; 404 → `不可见项目` with retry guard (exactly one fetch across remounts) plus logout clearing marks/caches (cross-user leak check); transient network error is NOT remembered invisible (honest 加载中, later retry resolves and renders). Bug found while writing these: invisible marks initially in a non-reactive Set — fixed to a $state record so the derived sidebar state updates.
- ECharts jsdom noise eliminated at the right boundaries, none suppressed: (a) `Chart.svelte` passes explicit real sizes to `echarts.init` (`host.clientWidth || 640 / host.clientHeight || height`) so jsdom's zero-layout no longer prints the `[ECharts] Can't get DOM width or height` warning — in a real browser the measured client size is used identically and ResizeObserver still handles resize; (b) `src/test-setup.ts` replaces `HTMLCanvasElement.getContext` ONLY because jsdom's native one is a throwing stub — a minimal 2d-context providing `measureText` (the single canvas usage behind zrender text metrics); SVG chart rendering itself stays real and is still asserted by regression tests; (c) previous `Range.getClientRects` shim retained for CodeMirror.
- Root `.dockerignore` added, tailored to `worker/Dockerfile` (which only COPYs `requirements-worker.txt` and `worker`): whitelist `**/*` + `!requirements-worker.txt` + `!worker` + `!worker/**` (last-match-wins), plus local-cache guards (`worker/__pycache__`, `worker/**/__pycache__`, `*.pyc`, `.pytest_cache`, `.venv`). Verified by reimplementing moby's patternmatcher semantics (per-path last-match-wins, `**` matching zero-or-more dirs incl. dot entries) walking the real tree: 24k files pruned to exactly {requirements-worker.txt, worker/*}; zero Dockerfile-needed files dropped; no build-context leakage of artifacts/, server/, docs/, frontend/, .venv, .pytest_cache. Docker build itself left to the parent as required.
- Outcomes (this round's actual results): frontend 37 tests / 11 files green with ZERO unhandled/stderr noise (先前 docs/tests 33 → +4 sidebar tests); svelte-check 0 errors / 0 warnings; `frontend/dist` rebuilt. Worker tests unchanged from item 18 (38/0). Backend untouched (54/0 previously verified). Parent owns worker image rebuild + live recheck; no services/Docker builds/credentials/tunnels/commits.
20. **Mobile results-viewport overflow (chart SVG sticky width) — live root cause + repair**
- Parent repeatable live failure reproduced and root-caused with real browser evidence (`artifacts/qa/supervisor-live-oce/overflow-probe.json` + before-gpxs): navigating desktop→results then resizing to 375 → `document.scrollWidth = 1133`; fresh 375 deep link was already clean (375). The only overflowing DOM was the ECharts chart subtree (svg/rect/g/path at width 1101px) — no table, layout or card element overflowed, so nothing was hidden; tables already keep their scroll container (`.table-wrap`, overflow-x auto).
- Root cause: `Chart.svelte` initialized echarts with an explicit measured width, and ECharts KEEPS the explicitly passed size across `chart.resize()`; the ResizeObserver handler called `chart.resize()` with no measured size, so after the desktop→mobile viewport change the SVG kept the desktop width (the desktop graph's 1101px width overflowed the 375 viewport).
- Fix (frontend only, root cause, not a mask): the ResizeObserver callback now re-measures the host on EVERY resize event and passes the measured size explicitly: `chart.resize({ width: host.clientWidth || undefined, height: host.clientHeight || undefined })`, skipping degenerate zero-layout resizes. Real charts stay fully rendered and responsive; jsdom real-SVG chart verification retained.
- Regression `frontend/src/lib/chartResize.test.ts` (2 tests) drives a real echarts SVG chart in jsdom: init/fallback width is measured (640), the SVG follows the host width through a 900px "desktop" resize AND shrinks 900→340 on the "mobile" resize (RED verified: with `chart.resize()` restored it fails on the sticky 900px SVG), and a zero-layout host callback is a harmless no-op.
- Live verification (`artifacts/qa/supervisor-live-oce/chartcam-checks.json`, verdict PASS, screenshots chartcam-*.png; used QA venv Playwright with the private account file, credentials never logged): desktop→375 resize settled: scrollWidth 375 == clientWidth 375, maxElementRight 375, chart SVG 1101→311; fresh mobile deep link: 375/375; mobile→desktop: 1440/1440 (SVG 1101); desktop→mobile again: 375/375 (SVG 311); zero pageerrors in all four cases. The earlier probe's "FAIL" verdict string was my own probe's flawed predicate (desktop SVG 1101 > 375 bench); the artifact as stored is PASS with the corrected invariant scrollWidth==clientWidth and svgWidth<=clientWidth per case.
- Outcomes: frontend **39 tests / 12 files** green (37→39, +2 chart regression), zero unhandled/stderr noise, svelte-check 0/0, `frontend/dist` rebuilt; charts resize, no overflow hiding, assertion `width == scrollWidth` preserved and now passing on the exact parent failure path. Worker/backend untouched (pytest 38/0, cargo 54/0 previously). Parent owns the live sidebar re-run + Docker rebuild; no services/ports/tunnel/production/supervisor changes; single worker, no parallel writers.
|