Add Serial and Wi-Fi quick settings
Reuse the existing settings view and typed controllers for accessible quick editing while keeping network credentials out of quick mode. Expand browser and layout coverage for focus, dismissal, bounds, and expiry.
This commit is contained in:
@@ -134,6 +134,8 @@ Web serial initialization is failure-isolated from the base HTTPS service: if th
|
||||
|
||||
### Browser admin backend
|
||||
|
||||
8D.17 adds UI-only Serial/Wi-Fi quick presentation in `web_ui.c`: one existing settings DOM host and the same validators/request controllers, never parallel quick/full editors. Quick mode logically selects Settings (terminal input disabled) while showing the previous receiving terminal behind a bounded nonmodal panel. Promotion retains the single draft/controller; dismissal clears drafts/fences reads and automatic checks, retains pending uncertainty and never replays a mutation. Full-page hover is non-destructive; explicit activation navigates the full page instead. Network quick mode hides/disables password controls and clears transient secrets, with only existing secret-free snapshots. No backend/transport responsibility changes. Contracts and validation limits: `docs/phase8d17_implementation.md`.
|
||||
|
||||
8D.8 adds an in-document admin-only Settings/Serial view and optional `GET /api/settings/serial`. It requires current cookie/principal/admin authorization, rejects bodies/queries and inherits ordinary-GET Origin/no-store policy. Eight working config/running fields fit a 256-byte response; `serial_service_get_snapshot()` takes the existing state mutex with zero wait, releasing it before encoding/send and returning unavailable on contention. No mutation, NVS, broker or socket-lifecycle operation occurs. Both hidden terminals continue draining; Settings input is disabled, refresh is explicit/single-flight with session identity checks that cannot supersede serial admission, and cancellation clears/fences the settings view. URI budget is now 17, sockets remain six/no LRU. The private adapter's startup-only exact-GET registration stages descriptor/name allocation before table publication, unlike installed IDF's public failure path; HTTPD retains normal free ownership. Only Settings uses that helper; existing registration callers remain unchanged. See `docs/phase8d8_implementation.md` for bounds, tests and target-pending evidence.
|
||||
|
||||
The 8D.6 document binds retained terminal state to its first validated username/role/session-stable CSRF tuple. Every later session adoption must match, otherwise both terminal hosts are hidden, both sockets/work are closed/fenced and a clean `/` document is required. Pagehide hides scrollback until same-session revalidation; no clearing is needed for unchanged-session restore or mode changes. Terminal-fit readiness uses successful-bounds caching and at most three generation-fenced animation-frame retries per external request, never unbounded polling.
|
||||
|
||||
@@ -57,10 +57,12 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
||||
- Dependencies: TinyUSB, broker, serial service
|
||||
- Flow: `USB host <-> USB task <-> broker`
|
||||
- Lifecycle: permanent owner task; broker client exists only while attached with host DTR asserted.
|
||||
- Constraint: callbacks retain the latest host line coding only for diagnostics; it never reconfigures UART1. UART1 framing and speed remain controlled by the serial configuration and its explicit persistence commands.
|
||||
- Constraint: callbacks retain the latest host line coding only for diagnostics; it never reconfigures UART1. UART1 framing and speed remain controlled by the serial configuration and its explicit persistence commands.
|
||||
|
||||
## Web and WebSocket serial
|
||||
|
||||
- **8D.17 quick settings (2026-09-13):** `web_ui.c` owns Serial/Wi-Fi status-trigger nonmodal popovers using the single existing settings DOM/controller, no parallel drafts/endpoints. Secret-free Network quick mode excludes password controls; full-page promotion preserves controller/nonsecret draft, dismissal fences reads/polling without replay. Hover/focus/click/tap, Escape/outside dismiss; full-page drafts protected from hover. Tests `tests/web_ui_session/{browser,network}.cjs`, `layout.py`:126 UI groups + renderer/HTML/CSP PASS; optional Chromium geometry blocked by sandbox, target/independent parent review pending. Baseline100,300/1,782,613 → final100,300/1,788,629 RAM/flash (+0/+6,016 B). CPU160/combined WS send/Broker/Display unchanged. Contract, exact tests and checklist: `docs/phase8d17_implementation.md`.
|
||||
|
||||
- **8D.16 Broker (2026-09-13):** `web_broker_settings.{c,h}` owns optional admin-only GET `/api/settings/broker`, GET/POST `/api/settings/broker-operation`; existing dispatcher queues only IDs. `web_ui.c` adds Serial/Display-style full-page rows and explicit confirmed assignment, no mutation on view/selection. 256-byte/four-receive request,2048-byte snapshot,96-byte result, one login-bound slot/no timer;33 handlers/six sockets, unchanged tasks/stacks/queue/assets/CPU160/combined WS send. Tests: cookie `--broker`6+shared, broker management/wrap, dispatcher, lifecycle25, UI119+HTML/CSP and broad regressions. Baseline100,196/1,765,233 B → final100,300/1,782,613 RAM/flash. Independent parent review and target sign-off pending. Contracts/resources/checklist: `docs/phase8d16_implementation.md`.
|
||||
|
||||
- **8D.14 Display (2026-09-09):** `web_display_settings.{c,h}` owns optional admin-only GET `/api/settings/display`, GET/POST `/api/settings/display-operation`; `web_ui.c` supplies Serial-style dim/off settings and bounded completion checks. `local_status_ui` owns generation-safe config/storage reservation shared with CLI; buttons do not edit timeouts. No I2C changes. 256-byte/four-receive request,128-byte snapshot,96-byte result, one slot/no timer;30 handlers/six sockets, unchanged tasks/stacks/queue/schema. Tests: cookie `--display` (7+shared), UI111, lifecycle23, dispatcher and broad regressions. Actual baseline100,100/1,748,513 B → final100,196 RAM/1,765,233 flash at160MHz. Target pending; exact API, reset ordering, deadlines, resource/validation limits: `docs/phase8d14_implementation.md`.
|
||||
|
||||
@@ -4,6 +4,10 @@ This file is working memory. Update it during active work and before handoff; do
|
||||
|
||||
## Development state
|
||||
|
||||
- **8D.17 independent-review P2 fixed (2026-09-13), independently confirmed resolved:** Quick dismissal now restores Serial/Admin state/input without xterm focus; automatic departure and adjacent-trigger switches preserve focus, Escape/Close focus only trigger. Observable Terminal.focus harness reproduced adjacent-trigger failure before fix;128 UI groups+renderer/HTML/CSP PASS, pio21.90s100,300 RAM/1,788,661 flash (+0/+32 B review delta; phase+0/+6,048 B), diff check PASS. Two new groups cover adjacent keyboard triggers and timed/outside-pointer dismissal/explicit focus for both terminal backgrounds. Existing uncommitted work preserved; no unrelated source/device/assets changes. Reviewer confirmed no new actionable findings and independently reran128 UI groups+renderer/HTML/CSP and diff checks PASS. Parent final build confirmation PASS6.98s,100,300/1,788,661 B; diff check PASS. Exact correction in `docs/phase8d17_implementation.md`; target/geometry limits remain.
|
||||
|
||||
- **8D.17 implemented (2026-09-13), user-authorized; host/build verified, independent parent review and target sign-off pending:** UI-only Serial/Wi-Fi quick popovers reuse the sole settings DOM/validators/controllers and typed endpoints. Hover/focus/click/tap, Escape/outside dismissal, bounded scrollable panel, full-page promotion, explicit Apply/Save; Network excludes password controls and clears transient input. Full-page hover cannot steal drafts; focused quick editor resists unrelated hover. Close fences reads/checks, preserves pending uncertainty/no replay; both terminals drain with selected input disabled, previous terminal restored on close. Worktree initially clean. PASS126 UI groups+renderer/HTML/CSP, cookie base/all existing variants, lifecycle25, dispatcher/canonical accounts/Network/broker, parser, transports/tickets/store, idle/diagnostics/performance/security/login regressions and diff check. Optional Chromium18-case geometry attempt failed before measurement (sandbox process-singleton socket/Crash Reports), no geometry pass. New test Serial ack fixture schema fixed and final suite/build passed. Baseline pio6.88s100,300 RAM/1,782,613 flash; final21.74s100,300/1,788,629 (+0/+6,016 B). CPU160 defaults/active/generated confirmed; signed-off combined WS/Broker8D.16/Display unchanged. No backend/API/assets/config/dependencies/icons/8D.18/8D.15/upload/erase/commit. Exact ownership, tests/limits/resources and target checklist: `docs/phase8d17_implementation.md`. Parent will independently review; no target/reserve/M3 sign-off.
|
||||
|
||||
- **8D.16 implemented (2026-09-13), user-authorized; host/build verified, independently reviewed; target sign-off pending:** Admin Settings/Broker has bounded detailed client rows and explicit confirmed writer assignment, no view/selection mutation, no8D.17/18 popovers/icons. `web_broker_settings` uses256-byte/four-receive requests,2048-byte snapshot,96-byte result, one login-isolated slot and existing typed dispatcher IDs/30-second dequeue deadline; no new timer/task/queue/stack/assets. Broker owns atomic zero-wait client/writer/lease snapshot and conditional generation+target check under force lock;29-bit client generations retire slots rather than wrap, separate saturated32-bit lease generation fences ABA/counter clears/dropped events, ordinary recovery retained.33 handlers/six sockets. PASS cookie Broker6+shared, canonical broker7+2 management/wrap, dispatcher, lifecycle25, UI119+five-view HTML/CSP, all existing cookie variants, canonical Network/accounts, parser294, console lifecycle/policy, transport/tickets/store, idle/diagnostics/performance/login and diff checks. Baseline pio7.14s100,196 RAM/1,765,233 flash; substantive-source final20.54s100,300/1,782,613 (+104/+17,380 B); final post-review confirmation21.20s unchanged. Initial worktree clean; CPU160 confirmed defaults/active/generated, signed-off combined WS send untouched. Tests fixed row-schema count and harness extraction/capacity/count issues. Independent final review found no actionable findings; reviewer reran broker7+2, cookie Broker/Display, dispatcher, lifecycle25, UI119+C/HTML/CSP, throughput and diff checks PASS. Parent final pio confirmation PASS6.93s at100,300/1,782,613 B; diff check PASS. Target/full-mix/HTTPD-dispatcher margins and user sign-off pending; no reserve/M3 claim. Exact API/wrap/resources/test limits and target checklist: `docs/phase8d16_implementation.md`. No upload/erase/commit/SDK changes. Older next8D.16 authorization wording below is historical.
|
||||
|
||||
- **8D.15 removed from plan (2026-09-09), explicit user scope decision:** Network diagnostics stay exclusive to the admin shell; no dedicated typed diagnostics endpoints/settings UI. Removed the planned deliverable from `docs/phase8d_plan.md`; retain later phase numbers, so next planned chunk after8D.14 is8D.16, only on separate request. Existing shell permissions and implemented Network settings/status unchanged. Documentation only; no firmware/build/device action or8D.14 target sign-off implied.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Phase 8D.17 — Serial / Wi-Fi quick popovers
|
||||
|
||||
## Status and scope (2026-09-13)
|
||||
|
||||
User-authorized implementation complete; host tests and firmware build verified. **Independent review complete; target/user sign-off pending.** No M3 or memory-reserve approval implied. Initial `git --no-optional-locks status --short` was empty.
|
||||
|
||||
Only authored `src/web_ui.c`, UI regressions and phase/agent documentation changed. No backend/API, handler budget, dispatcher, task, stack, queue, schema, dependency, icon, generated asset, SDK or configuration change. No 8D.18 client/writer dialogs or 8D.15 diagnostics. Broker8D.16/Display behavior and the signed-off combined binary WS send at CPU160 are preserved; no upload, erase, commit or branch creation.
|
||||
|
||||
## Independent-review P2 correction (2026-09-13)
|
||||
|
||||
Reviewer identified that `closeQuick(false)` restored terminal selection through an unconditional xterm focus, stealing keyboard focus during adjacent-trigger switching and timed departure to an outside control. `selectTerminal` now has an independent focus option (default unchanged for ordinary navigation); quick dismissal restores selection/input state with terminal focus suppressed. Automatic dismissal/switching preserves focus. Escape/Close deliberately focus only the originating trigger, never transiently xterm.
|
||||
|
||||
`Terminal.focus()` in the Node harness now updates activeElement and counts calls. New adjacent Wi-Fi→Serial→Wi-Fi keyboard regression reproduced the failure before the fix. Two added groups cover that switch, timed outside-focus and outside-pointer dismissal for both Serial/Admin backgrounds, and Escape/Close trigger-only focus. Final `python3 tests/web_ui_session/run.py` PASS **128 groups plus renderer/HTML/CSP**; `pio run` PASS21.90s, **100,300 B RAM / 1,788,661 B flash** (+0/+32 B versus initial8D.17 build; +0/+6,048 B versus phase baseline); `git --no-pager diff --check` PASS. Earlier126-group/build results below are pre-review history. Existing uncommitted phase work preserved. Independent reviewer confirmed the P2 resolved with no new actionable issue and independently reran128 UI groups+renderer/HTML/CSP and diff checks PASS. Parent final build confirmation PASS6.98s,100,300 B RAM/1,788,661 B flash; diff check PASS. Target sign-off remains pending; geometry not rerun, prior sandbox limitation stands.
|
||||
|
||||
## UI and ownership contract
|
||||
|
||||
- Existing Wi-Fi/Serial status values are native labeled buttons, initially disabled and enabled only after validated admin session adoption. Ordinary users retain status text but cannot open settings through them; server authorization is unchanged.
|
||||
- One existing `serial-settings` DOM host, never cloned or simultaneously mounted. Serial uses the completed framing/lifecycle/persistence form. Wi-Fi uses the completed AP/boot, four STA-profile, connection and mDNS controls, **excluding password action/input/status from the quick presentation**. No new settings fields or endpoint semantics.
|
||||
- The same snapshot validators, SSID UTF-8/hex conversion, edit validation, confirmations, single-flight request controllers, generation/currentness checks, operation IDs and bounded result polling serve full and quick views. Network fetch remains the strict secret-free typed snapshot, never a saved-credentials fetch. Password controls are hidden and disabled in quick mode; transient input is cleared on entry, navigation and promotion, with keep-existing semantics. No saved PSK can be returned/prefilled by these endpoints.
|
||||
- Mouse hover or keyboard focus opens without mutation. Click/native Enter/Space/tap enters the same content and focuses Close. Touch pointer-enter does not open preemptively. Repeated activation does not refetch/reset the current quick form. Opening performs session validation and one existing domain GET, not an operation POST or operation-result GET.
|
||||
- Nonmodal dialog semantics, accessible name, `aria-controls`, `aria-haspopup=dialog`, `aria-expanded`, visible focus outline, native controls and explicit Close/full-page link. No focus trap. Focused content resists unrelated hover; switching by explicit activation remains available.
|
||||
- A single 250ms pointer/focus departure timer bridges trigger-to-content movement. Departure closes only if neither region is hovered and focus is outside both. Escape closes and restores trigger focus; automatic reopening of that dismissed trigger is suppressed until departure, while explicit activation can reopen. Outside pointer-down closes without stealing focus back to the trigger.
|
||||
- One fixed top-right panel, 8px viewport inset, at most 600px wide and viewport-minus16px tall (dynamic viewport height with fallback), internally scrollable. No icons were changed. Geometry fixture checks are implemented but **did not run successfully in this sandbox** (see below).
|
||||
- Full-page link promotes the **same** host/controller and draft, without refetch, POST, result replay or poll cancellation. Password transient state is cleared. Close/switch instead clears drafts and aborts/fences reads and browser automatic checks; admitted backend work is not cancelled. Pending/unknown outcome gates remain retained for explicit Check Result on return; opening never resumes polling or replays a mutation.
|
||||
- When a full Settings page already owns a draft, hover/focus on a status trigger does nothing. Explicit click/Enter/tap navigates the existing full page to that domain and focuses its navigation control. This deliberate exception prevents hover from discarding a full-page draft or introducing a second DOM owner.
|
||||
- The previous Serial/Admin terminal remains visible behind the quick panel and continues receiving. Logical Settings selection disables both terminal input routes while quick content is open. Close restores the previous terminal selection; no socket, broker-client identity, lease or binary protocol change. Session revalidation also respects this visible-background selection. Expiry/logout/pagehide use existing clear/fence/close semantics; no secret or mutation replay on restore.
|
||||
- Apply changes RAM; Save persists device working values, not unsubmitted browser drafts. Existing Serial discard warnings, Network connection-loss confirmations/recovery guidance and uncertain-result handling remain visible. Network profile selection is only editing, never connection selection or mutation. Actual explicit Network operations can disrupt network clients; UART0 administration and native USB UART1 remain independent recovery paths.
|
||||
|
||||
## Validation executed
|
||||
|
||||
All commands from the repository root, bounded at 120 seconds per tool invocation (geometry at 60 seconds, browser subprocess at 30 seconds). All below passed, except the explicitly failed optional geometry attempt:
|
||||
|
||||
```sh
|
||||
python3 tests/web_ui_session/run.py
|
||||
python3 tests/web_cookie_auth/run.py
|
||||
python3 tests/web_cookie_auth/run.py --settings
|
||||
python3 tests/web_cookie_auth/run.py --serial-settings
|
||||
python3 tests/web_cookie_auth/run.py --accounts
|
||||
python3 tests/web_cookie_auth/run.py --admin
|
||||
python3 tests/web_cookie_auth/run.py --network
|
||||
python3 tests/web_cookie_auth/run.py --display
|
||||
python3 tests/web_cookie_auth/run.py --broker
|
||||
python3 tests/web_admin_transport/server_lifecycle.py
|
||||
python3 tests/admin_console_boundary/run.py
|
||||
python3 tests/admin_console_boundary/accounts.py
|
||||
python3 tests/admin_console_boundary/lifecycle.py
|
||||
python3 tests/admin_ssh_policy/run.py
|
||||
python3 tests/web_network_settings/run.py
|
||||
python3 tests/session_broker_diagnostics/run.py
|
||||
python3 tests/web_auth_parse/run.py
|
||||
python3 tests/web_session_store/run.py
|
||||
python3 tests/web_session_store/run.py --serial
|
||||
python3 tests/web_admin_transport/run.py --tickets
|
||||
python3 tests/web_httpd_idle/run.py
|
||||
python3 tests/web_diagnostics/run.py
|
||||
python3 tests/web_serial_performance/run.py
|
||||
python3 tests/web_security/run.py
|
||||
python3 tests/web_login_ui/run.py
|
||||
pio run
|
||||
git --no-pager diff --check
|
||||
```
|
||||
|
||||
Final UI: **126 behavior groups** (119 existing +7 quick-specific) executing production C-rendered JS, plus C response/header/failure, parsed HTML/labels/unique ownership and exact unchanged inline-loader CSP hash checks. New focus/containment doubles exercise hover/touch activation, stationary-pointer Escape suppression, input gating/binary drains, outside dismissal and delayed snapshot fencing, pagehide, full-page draft/promotion ownership, actual explicit Apply and Save bodies, pending-result non-replay, strict Network schema/secret exclusion and endpoint401. Full-page regression suites exercise the shared controllers' remaining validation/result/identity cases. These are deterministic DOM/fetch/clock/socket doubles, not real browser event dispatch, radio, NVS or target timing proof.
|
||||
|
||||
During test development, a new Serial acknowledgement fixture incorrectly included Network's fourth `error` field; strict production validation correctly rejected it, making the promotion-poll assertion fail. Fixed both new Serial fixtures to the existing exact three-field schema and reran the suite/build successfully. Self-review also added focused-editor hover protection and stationary-pointer Escape suppression; this is not independent review.
|
||||
|
||||
Optional real geometry attempted:
|
||||
|
||||
```sh
|
||||
WEB_UI_CHROMIUM=/usr/bin/chromium-browser python3 tests/web_ui_session/run.py
|
||||
```
|
||||
|
||||
**Unavailable, not passed:** Chromium failed before measurement with read-only `~/.config/chromium/Crash Reports/settings.dat` and fatal process-singleton `socket() failed: Operation not permitted`; ptrace also denied. No sandbox escape or installed dependency change attempted. The optional fixture now covers 18 cases: existing four full settings views at320/600/1200px plus Serial/Wi-Fi quick views at those widths and360px height; checks panel bounds/overflow, scrolling and password/navigation exclusion. It strips the authenticated loader/assets and uses fixture content: even a future pass is not live-app interaction or target visual sign-off.
|
||||
|
||||
## Build/resources
|
||||
|
||||
| Measurement | RAM | Flash | Time |
|
||||
|---|---:|---:|---:|
|
||||
| Actual pre-edit baseline `pio run` | 100,300 B | 1,782,613 B | 6.88 s |
|
||||
| Final substantive source `pio run` | 100,300 B | 1,788,629 B | 21.74 s |
|
||||
| Delta | **0 B** | **+6,016 B** | incremental build times not comparable |
|
||||
|
||||
CPU160 independently confirmed in tracked defaults, existing active sdkconfig and generated sdkconfig.h. PlatformIO's board banner says240MHz but does not override those actual settings. No new firmware tasks, buffers or endpoint handlers:33 handlers/six sockets retained. Browser costs are bounded quick state, listeners and one250ms departure timer; settings snapshots/controllers are reused. No runtime heap, HTTPD/dispatcher stack, responsiveness, transient reserve or loaded-client measurement was performed. Existing full-mix throughput acceptance is not reopened or expanded.
|
||||
|
||||
## Pending target / parent checklist
|
||||
|
||||
- [x] Independent code/test review and P2 fix confirmation; no remaining actionable findings.
|
||||
- [ ] Desktop mouse: enter trigger, cross to scrollable content, move between fields, leave both; no flicker/refetch/draft loss. Escape while pointer remains stationary stays dismissed; Close and explicit reopen work.
|
||||
- [ ] Keyboard: Tab-focus disclosure, Enter/Space activation, all fields/actions/full-page link reachable, Shift-Tab departure, Escape focus return and no serial/admin key leakage. Screen reader names/expanded/nonmodal semantics verified.
|
||||
- [ ] Touch: tap both triggers, edit/select without unintended submission, scroll panel, outside-tap/Close, rotate/resize/zoom/software keyboard at narrow/short viewports; no inaccessible controls or horizontal overflow. Run optional geometry where Chromium can start.
|
||||
- [ ] Full-page draft (including transient Network password) untouched by mere trigger hover/focus; explicit navigation clears old context. Quick-to-full preserves only intended nonsecret draft/controller state.
|
||||
- [ ] Compare quick/full/CLI Serial settings and explicit Apply/Save; no Save of browser-only drafts. Reboot persistence and serial reconfiguration discard semantics verified.
|
||||
- [ ] AP/four STA profiles/SSID hex and UTF-8, omitted PSK preservation, explicit connection confirmations, accepted-versus-online distinction, mDNS and explicit persistence. No secret shown or cached; recover after actual network loss via STA/AP, UART0/native USB.
|
||||
- [ ] Delayed/lost acknowledgement, stale snapshot, concurrent CLI/tab edits, dismissed pending operation, Check Result/Refresh recovery; no opening/selection/restore replay. Session expiry, role change, logout, bfcache and late response isolation.
|
||||
- [ ] Both browser terminals plus physical USB/SSH/full mix at signed-off160MHz/230400 baud: sockets/lease survive UI actions and bounded output continues; capture drop/latency/heap/HTTPD-dispatcher margins without claiming new reserve approval.
|
||||
|
||||
No target checklist item was executed or signed off by this implementation.
|
||||
@@ -188,7 +188,7 @@ Typed operations must preserve subsystem owner/lock/persistence contracts and co
|
||||
| **8D.13 — Wi-Fi secrets and connection controls** | Explicit password replacement/clear semantics, bounded transient input, profile selection/reconnect and AP policy actions using manager-owned operations. | Preserve existing secrets when fields are omitted; never prefill saved secrets; document apply/save and likely connection loss; reconnect via STA/AP and verify UART0/USB recovery. No background secret fetch or general credential export. |
|
||||
| **8D.14 — Display settings** | **Implemented, host/build verified; target pending.** Typed local display configuration and explicit persistence via `local_ui_config`/generation-checked public UI APIs; [contract/evidence](phase8d14_implementation.md). | Host limits/storage/CLI-generation/activity/lifecycle regressions pass; actual save/reboot, absent-display and concurrent buttons/CLI target checklist remains pending. No I2C ownership changes or electrical diagnostics UI. |
|
||||
| **8D.16 — Broker client visibility and writer transfer** | **Implemented, host/build verified; independent parent review and target sign-off pending.** Admin-only detailed snapshot plus explicit confirmed assignment through existing dispatcher/broker; smallest owner changes add atomic lease-version/target validation and nonwrapping IDs. [Contract/evidence](phase8d16_implementation.md). | Host stale/disconnected/reused target and lease ABA rejection, one writer, admin denial, bounded/session-isolated results, lifecycle and UI non-mutation regressions pass. Concurrent physical USB/SSH/browser/full-mix and stack-margin checklist remain pending. No transfer on page open/selection; no8D.17/18 popovers/icons. |
|
||||
| **8D.17 — Serial/Wi-Fi quick popovers** | UI-only reuse of completed typed endpoints, with full-page links and shared validation; start in `web_ui`. | Hover, focus, click/tap parity, Escape/outside-click dismissal, no mutation on opening, explicit apply/save, no secret exposure. No duplicate backend or new settings scope. |
|
||||
| **8D.17 — Serial/Wi-Fi quick popovers** | **Implemented, host/build verified; independent parent review and target sign-off pending.** UI-only reuse of completed typed endpoints through one settings DOM/controller, full-page link and shared validation; `web_ui`. [Contract/evidence](phase8d17_implementation.md). | Hover/focus/click/tap, Escape/outside dismissal, no opening/selection mutation, explicit Apply/Save, Network password exclusion; draft/session/pending-operation regressions pass. Full-page hover deliberately preserves its existing draft; explicit activation navigates that page. Optional geometry blocked by sandbox; real pointer/keyboard/touch/full-mix checklist pending. No duplicate backend/new settings scope/icons/8D.18. |
|
||||
| **8D.18 — Client/writer contextual dialogs** | Reuse 8D.16 for live client popover and confirmed Active writer dialog. | Accessible pointer/keyboard/touch paths; refresh preserves explicit selection safely; stale confirmation is rejected visibly; normal users retain only ordinary status. No new writer policy. |
|
||||
| **8D.19 — Ordinary service/session controls** | Typed service status and targeted disconnect/start/stop controls, excluding actions that cut off the invoking HTTPS session; start in existing service APIs and generation-safe snapshots. | Explicit scope/confirmation, stale target rejection, owner-safe execution and failure isolation. Stop/start and disconnect do not clear settings/identities. Split by service if more than one owner adaptation is necessary. |
|
||||
| **8D.20 — Self-affecting service actions and reboot** | Confirmed typed operations for connection-losing HTTPS/Wi-Fi/service actions and reboot, reusing lifecycle behavior established in 8D.7/8D.13. | Acknowledgement/pending-action handling is bounded, no duplicate action on UI retry, connection loss is explained, recovery and reconnect work. No new unbounded queue or certificate/key rotation yet. |
|
||||
|
||||
+65
-10
@@ -26,6 +26,9 @@ static const char s_index_html[] =
|
||||
"--good:#52d68b;--warn:#ffc857;--bad:#ff6b7a;--radius:14px}\n"
|
||||
"*{box-sizing:border-box}\n"
|
||||
"[hidden]{display:none!important}\n"
|
||||
".quick-trigger{background:none;border:0;padding:0;color:inherit;text-align:left;cursor:pointer}.quick-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:3px}\n"
|
||||
".settings-page[data-quick='true']{position:fixed;z-index:10;top:8px;right:8px;width:min(600px,calc(100vw - 16px));max-height:calc(100vh - 16px);max-height:calc(100dvh - 16px);overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:16px;box-shadow:0 8px 32px #0008}\n"
|
||||
"[data-quick='true']>#settings-navigation,[data-quick='true'] #network-password-mode-label,[data-quick='true'] #network-password-label,[data-quick='true'] #network-password-status{display:none}\n"
|
||||
"html,body{height:100%;margin:0;overflow:hidden}\n"
|
||||
"body{background:radial-gradient(circle at top left,#142033 0,var(--bg) 42rem);"
|
||||
"color:var(--text);font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"
|
||||
@@ -154,9 +157,9 @@ static const char s_index_html[] =
|
||||
"<div class=\"status-item\"><span class=\"label\">Broker clients</span>"
|
||||
"<span id=\"broker-clients\" class=\"value\">—</span></div>\n"
|
||||
"<div class=\"status-item wide\"><span class=\"label\">Wi-Fi</span>"
|
||||
"<span id=\"wifi-summary\" class=\"value\">Loading…</span></div>\n"
|
||||
"<button type=\"button\" id=\"quick-network\" class=\"quick-trigger\" aria-label=\"Wi-Fi quick settings\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"wifi-summary\" class=\"value\">Loading…</span></button></div>\n"
|
||||
"<div class=\"status-item wide\"><span class=\"label\">Serial</span>"
|
||||
"<span id=\"serial-summary\" class=\"value\">Loading…</span></div>\n"
|
||||
"<button type=\"button\" id=\"quick-serial\" class=\"quick-trigger\" aria-label=\"Serial quick settings\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"serial-summary\" class=\"value\">Loading…</span></button></div>\n"
|
||||
"</div>\n"
|
||||
"<div class=\"panel controls\">\n"
|
||||
"<div class=\"button-row\">\n"
|
||||
@@ -183,7 +186,8 @@ static const char s_index_html[] =
|
||||
"<div id=\"terminal\" class=\"terminal-host\"></div>\n"
|
||||
"<div id=\"admin-terminal\" class=\"terminal-host\" hidden></div>\n"
|
||||
"<section id=\"serial-settings\" class=\"settings-page\" aria-label=\"Serial settings\" hidden>"
|
||||
"<div class=\"serial-actions\"><button id=\"settings-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial settings</button>"
|
||||
"<div id=\"quick-header\" hidden><h2 id=\"quick-title\">Quick settings</h2><button id=\"quick-close\" class=\"button\" type=\"button\">Close quick settings</button> <a id=\"quick-full\" href=\"#serial-settings\">Open full settings page</a><p class=\"connection-detail\">Opening and selecting never applies changes. Apply changes RAM; Save persists working device values, not drafts. Closing discards drafts; admitted work is not cancelled. Password changes are available only on the full Network page.</p></div>"
|
||||
"<div id=\"settings-navigation\" class=\"serial-actions\"><button id=\"settings-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial settings</button>"
|
||||
"<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button>"
|
||||
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button>"
|
||||
"<button id=\"settings-display\" class=\"button\" type=\"button\" aria-pressed=\"false\">Display</button><button id=\"settings-broker\" class=\"button\" type=\"button\" aria-pressed=\"false\">Broker</button></div>"
|
||||
@@ -224,9 +228,9 @@ static const char s_index_html[] =
|
||||
"<label id=\"network-security-label\">Security<select id=\"network-security\"><option value=\"mixed\">WPA2 or stronger (mixed)</option><option value=\"wpa3\">WPA3</option></select></label>"
|
||||
"<label>SSID editor<select id=\"network-ssid-mode\"><option value=\"text\">Ordinary text (UTF-8)</option><option value=\"hex\">Exact bytes (hex)</option></select></label>"
|
||||
"<label>SSID<input id=\"network-ssid\" maxlength=\"256\" autocomplete=\"off\" spellcheck=\"false\"></label>"
|
||||
"<label>Password action<select id=\"network-password-mode\"><option value=\"keep\">Keep existing (default)</option>"
|
||||
"<label id=\"network-password-mode-label\">Password action<select id=\"network-password-mode\"><option value=\"keep\">Keep existing (default)</option>"
|
||||
"<option value=\"replace\">Replace</option><option id=\"network-password-clear\" value=\"clear\">Clear (disabled STA only)</option></select></label>"
|
||||
"<label>New password<input id=\"network-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\" disabled></label></div>"
|
||||
"<label id=\"network-password-label\">New password<input id=\"network-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\" disabled></label></div>"
|
||||
"<p id=\"network-password-status\" class=\"connection-detail\"></p><p id=\"network-ssid-detail\" class=\"connection-detail\" role=\"status\"></p>"
|
||||
"<p class=\"connection-detail\">SSID maximum: 32 bytes, not characters. Text entry encodes UTF-8 once; hex preserves arbitrary bytes including zero. "
|
||||
"Switching modes is lossless or refused. Password replacement requires 8–63 printable ASCII characters; blank never clears. "
|
||||
@@ -393,7 +397,56 @@ static const char s_app_js[] =
|
||||
" element('refresh-settings').disabled = busy;\n"
|
||||
" element('serial-result').disabled = busy;\n"
|
||||
"}\n"
|
||||
"let quick = null, quickLeaveTimer = 0, quickSuppressFocus = false, quickDismissed = null;\n"
|
||||
"function resetQuick() {\n"
|
||||
" window.clearTimeout(quickLeaveTimer); quickLeaveTimer = 0; quick = null;\n"
|
||||
" settingsHost.setAttribute('data-quick', 'false'); settingsHost.setAttribute('role', 'region');\n"
|
||||
" element('quick-header').hidden = true; settingsHost.setAttribute('aria-label', 'Settings');\n"
|
||||
" for (const domain of ['serial','network']) element('quick-' + domain).setAttribute('aria-expanded', 'false');\n"
|
||||
"}\n"
|
||||
"function closeQuick(focus = false) {\n"
|
||||
" if (!quick) return;\n"
|
||||
" const {previous, trigger} = quick; quickDismissed = trigger; resetQuick(); selectTerminal(previous, true, false);\n"
|
||||
" if (focus) { quickSuppressFocus = true; trigger.focus(); quickSuppressFocus = false; }\n"
|
||||
"}\n"
|
||||
"function openQuick(domain, activate = false) {\n"
|
||||
" if (quickSuppressFocus || !sessionVerified || accountRole !== 'admin' || suspended || unloading || navigating || loggingOut) return;\n"
|
||||
" if (!activate && quickDismissed === element('quick-' + domain)) return;\n"
|
||||
" if (!activate && quick && settingsHost.contains(document.activeElement)) return;\n"
|
||||
" if (activate) quickDismissed = null;\n"
|
||||
" if (quick?.domain === domain) { if (activate) element('quick-close').focus(); return; }\n"
|
||||
" if (quick) closeQuick();\n"
|
||||
" // Full-page drafts retain their sole DOM owner; only explicit activation navigates there.\n"
|
||||
" if (selected === 'settings') { if (activate) { selectSettingsDomain(domain); element('settings-' + domain).focus(); } return; }\n"
|
||||
" const previous = selected, trigger = element('quick-' + domain);\n"
|
||||
" selectTerminal('settings', false); selectSettingsDomain(domain, false); clearNetworkSecret();\n"
|
||||
" quick = {domain, previous, trigger}; settingsHost.setAttribute('data-quick', 'true');\n"
|
||||
" settingsHost.setAttribute('role', 'dialog'); settingsHost.setAttribute('aria-label', domain === 'serial' ? 'Serial quick settings' : 'Wi-Fi quick settings');\n"
|
||||
" element('quick-title').textContent = domain === 'serial' ? 'Serial quick settings' : 'Wi-Fi quick settings';\n"
|
||||
" element('quick-header').hidden = false; trigger.setAttribute('aria-expanded', 'true');\n"
|
||||
" terminalHost.hidden = previous !== 'serial'; adminHost.hidden = previous !== 'admin';\n"
|
||||
" refreshSettings(); if (activate) element('quick-close').focus();\n"
|
||||
"}\n"
|
||||
"function quickContains(target) { return !!quick && !!target && (settingsHost.contains(target) || quick.trigger.contains(target)); }\n"
|
||||
"function quickLeave() {\n"
|
||||
" window.clearTimeout(quickLeaveTimer); if (!quick) return;\n"
|
||||
" quickLeaveTimer = window.setTimeout(() => { quickLeaveTimer = 0; if (quick && !quickContains(document.activeElement) && !settingsHost.matches(':hover') && !quick.trigger.matches(':hover')) closeQuick(); }, 250);\n"
|
||||
"}\n"
|
||||
"for (const domain of ['serial','network']) {\n"
|
||||
" const trigger = element('quick-' + domain);\n"
|
||||
" trigger.addEventListener('pointerenter', event => { if (event.pointerType === 'mouse') openQuick(domain); });\n"
|
||||
" trigger.addEventListener('focus', () => openQuick(domain));\n"
|
||||
" trigger.addEventListener('click', () => openQuick(domain, true));\n"
|
||||
" trigger.addEventListener('pointerleave', () => { if (quickDismissed === trigger) quickDismissed = null; quickLeave(); }); trigger.addEventListener('focusout', () => { if (quickDismissed === trigger) quickDismissed = null; quickLeave(); });\n"
|
||||
"}\n"
|
||||
"settingsHost.addEventListener('pointerenter', () => window.clearTimeout(quickLeaveTimer));\n"
|
||||
"settingsHost.addEventListener('pointerleave', quickLeave); settingsHost.addEventListener('focusout', quickLeave);\n"
|
||||
"element('quick-close').addEventListener('click', () => closeQuick(true));\n"
|
||||
"element('quick-full').addEventListener('click', event => { event.preventDefault(); if (!quick) return; resetQuick(); clearNetworkSecret(); networkButtons(); terminalHost.hidden = adminHost.hidden = true; element('settings-' + settingsDomain).focus(); });\n"
|
||||
"window.addEventListener('pointerdown', event => { if (quick && !quickContains(event.target)) closeQuick(); });\n"
|
||||
"window.addEventListener('keydown', event => { if (quick && event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeQuick(true); } });\n"
|
||||
"function clearSettings() {\n"
|
||||
" resetQuick();\n"
|
||||
" clearAccounts(); clearNetwork(); clearDisplay(); clearBroker();\n"
|
||||
" if (!serialAuto && serialOperationPending) element('serial-operation-detail').textContent = serialOutcomeWarning + 'Operation outcome pending or unknown. Select Check Result on return; navigation does not cancel backend work.';\n"
|
||||
" stopSerialAuto(true);\n"
|
||||
@@ -870,7 +923,7 @@ static const char s_app_js[] =
|
||||
" if (accountPending) element('account-operation-detail').textContent = accountWarning + 'Outcome pending or unknown. Check Result on return; navigation does not cancel backend work.';\n"
|
||||
" accountButtons();\n"
|
||||
"}\n"
|
||||
"function selectSettingsDomain(domain) {\n"
|
||||
"function selectSettingsDomain(domain, read = true) {\n"
|
||||
" if (!sessionVerified || accountRole !== 'admin' || selected !== 'settings' || domain === settingsDomain) return;\n"
|
||||
" clearSettings(); settingsDomain = domain; settingsHost.hidden = false;\n"
|
||||
" element('serial-settings-content').hidden = domain !== 'serial'; element('account-settings').hidden = domain !== 'accounts'; element('network-settings').hidden = domain !== 'network';\n"
|
||||
@@ -878,7 +931,7 @@ static const char s_app_js[] =
|
||||
" element('broker-settings').hidden = domain !== 'broker'; element('settings-broker').setAttribute('aria-pressed', String(domain === 'broker'));\n"
|
||||
" element('settings-network').setAttribute('aria-pressed', String(domain === 'network'));\n"
|
||||
" element('settings-serial').setAttribute('aria-pressed', String(domain === 'serial')); element('settings-accounts').setAttribute('aria-pressed', String(domain === 'accounts'));\n"
|
||||
" refreshSettings();\n"
|
||||
" if (read) refreshSettings();\n"
|
||||
"}\n"
|
||||
"async function refreshAccounts() {\n"
|
||||
" if (!accountsLive() || accountsAbort) return;\n"
|
||||
@@ -1040,6 +1093,7 @@ static const char s_app_js[] =
|
||||
" net('password').disabled = blocked || net('password-mode').value !== 'replace';\n"
|
||||
" net('refresh').disabled = net('result').disabled = busy;\n"
|
||||
" net('password-clear').hidden = net('password-clear').disabled = net('target').value === 'ap';\n"
|
||||
" if (quick) { clearNetworkSecret(); net('password-mode').disabled = true; }\n"
|
||||
"}\n"
|
||||
"function clearNetwork() {\n"
|
||||
" clearNetworkSecret();\n"
|
||||
@@ -1295,7 +1349,7 @@ static const char s_app_js[] =
|
||||
" adminDetail.textContent = detail;\n"
|
||||
" updateControls();\n"
|
||||
"}\n"
|
||||
"function selectTerminal(mode) {\n"
|
||||
"function selectTerminal(mode, read = true, focus = true) {\n"
|
||||
" if (mode === selected) return;\n"
|
||||
" if (unloading || navigating || loggingOut || !sessionVerified || (mode !== 'serial' && accountRole !== 'admin')) return;\n"
|
||||
" clearSettings();\n"
|
||||
@@ -1320,7 +1374,7 @@ static const char s_app_js[] =
|
||||
" settingsHost.hidden = mode !== 'settings';\n"
|
||||
" element('terminal-title').textContent = mode === 'serial' ? 'Live serial stream' : mode === 'admin' ? 'Administration shell' : 'Settings';\n"
|
||||
" lastFitWidth = lastFitHeight = 0; updateControls(); scheduleFit();\n"
|
||||
" if (mode === 'settings') refreshSettings(); else (mode === 'serial' ? terminal : adminTerminal).focus();\n"
|
||||
" if (mode === 'settings') { if (read) refreshSettings(); } else if (focus) (mode === 'serial' ? terminal : adminTerminal).focus();\n"
|
||||
"}\n"
|
||||
"async function openAdmin() {\n"
|
||||
" if (accountRole !== 'admin' || selected !== 'admin' || unloading || navigating || loggingOut || suspended || !csrf || adminSocket || adminAbort) return;\n"
|
||||
@@ -1493,8 +1547,9 @@ static const char s_app_js[] =
|
||||
" accountRole = payload.role;\n"
|
||||
" element('terminal-selector').hidden = accountRole !== 'admin';\n"
|
||||
" if (accountRole !== 'admin') { closeAdmin(); selectTerminal('serial'); }\n"
|
||||
" terminalHost.hidden = selected !== 'serial'; adminHost.hidden = selected !== 'admin';\n"
|
||||
" terminalHost.hidden = (quick?.previous || selected) !== 'serial'; adminHost.hidden = (quick?.previous || selected) !== 'admin';\n"
|
||||
" settingsHost.hidden = selected !== 'settings' || loggingOut || suspended;\n"
|
||||
" for (const domain of ['serial','network']) element('quick-' + domain).disabled = accountRole !== 'admin' || loggingOut || suspended;\n"
|
||||
" scheduleFit();\n"
|
||||
" const deadline = Date.now() + payload.expires_in * 1000;\n"
|
||||
" sessionDeadline = sessionDeadline ? Math.min(sessionDeadline, deadline) : deadline;\n"
|
||||
|
||||
@@ -15,6 +15,10 @@ doubles. It reuses the HTTPD stub text from `tests/web_login_ui/run.py`, without
|
||||
importing/executing that runner. Node executes the actual C-rendered application
|
||||
and inline asset-failure script, not a separately maintained implementation.
|
||||
|
||||
Current 8D.17 run: **126 behavior groups**, plus production renderer/HTML/CSP checks. Seven new quick groups cover mouse/focus/touch activation, single DOM ownership, focused-editor hover stability, Escape/outside dismissal, late reads/expiry, terminal input/drain isolation, full-page draft/promotion preservation, explicit Apply/Save and pending non-replay, Network secret exclusion/strict schema/401. `Element` focus/containment/hover and events are doubles, not browser event-dispatch proof.
|
||||
|
||||
Optional geometry: `WEB_UI_CHROMIUM=/usr/bin/chromium-browser python3 tests/web_ui_session/run.py`. The fixture covers 18 cases (four full views plus Serial/Wi-Fi quick panels at320/600/1200px, quick height360), including viewport bounds/scrolling and secret-control exclusion. The 8D.17 attempt was **blocked before measurement** by sandbox socket/Crash Reports restrictions; no geometry pass claimed. Full evidence/limits and target checklist: `docs/phase8d17_implementation.md`.
|
||||
|
||||
Coverage:
|
||||
|
||||
- Resource selection, NULL/invalid input, setter/send failure propagation,
|
||||
|
||||
@@ -30,7 +30,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
|
||||
}
|
||||
class Terminal {
|
||||
constructor(options) { this.options = options; this.writes = []; terminals.push(this); }
|
||||
loadAddon() {} open() {} focus() {} resize(cols, rows) { this.cols = cols; this.rows = rows; } onData(fn) { this.input = fn; }
|
||||
loadAddon() {} open(host) { this.host = host; this.focusCalls = 0; } focus() { ++this.focusCalls; this.host.focus(); } resize(cols, rows) { this.cols = cols; this.rows = rows; } onData(fn) { this.input = fn; }
|
||||
write(bytes, callback) { this.writes.push([...bytes]); if (this.holdWrites) (this.pending ||= []).push(callback); else callback?.(); }
|
||||
}
|
||||
const window = {confirm: () => true, addEventListener: on, removeEventListener(k, fn) { events[k] = (events[k] || []).filter(f => f !== fn); },
|
||||
@@ -48,7 +48,10 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
|
||||
appendChild(child) { this.children.push(child); child.parentNode = this; return child; }
|
||||
setAttribute(k, v) { this[k] = v; }
|
||||
getBoundingClientRect() { return {width: 100, height: 100}; }
|
||||
addEventListener(k, fn) { this[k] = fn; }
|
||||
addEventListener(k, fn) { this[k === 'focus' ? 'onfocus' : k] = fn; }
|
||||
focus() { context.document.activeElement = this; this.onfocus?.(); }
|
||||
contains(node) { return node === this || this.children.some(child => child.contains(node)); }
|
||||
matches() { return !!this.hovered; }
|
||||
}
|
||||
const context = vm.createContext({window, document: {
|
||||
createElement: tag => new Element(tag),
|
||||
@@ -80,13 +83,128 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
|
||||
const match = [...timers].find(([, t]) => t.ms === ms); assert.ok(match, `missing timer ${ms}`);
|
||||
const [id, t] = match; if (!t.interval) timers.delete(id); t.fn();
|
||||
};
|
||||
return {nodes, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire,
|
||||
return {nodes, document: context.document, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire,
|
||||
click: id => nodes[id].click(), elapse: ms => { now += ms; }, window};
|
||||
}
|
||||
async function connected() { const b = browser(); b.start(); await tick(); assert.equal(b.sockets.length, 1); return b; }
|
||||
let passed = 0;
|
||||
async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', name); }
|
||||
(async () => {
|
||||
await test('Quick Serial hover/focus/tap reuses one form, opens read-only and preserves socket/lease and binary drains', async () => {
|
||||
const b = browser({role: 'admin'}); b.start(); await tick();
|
||||
const trigger = b.nodes['quick-serial'], host = b.nodes['serial-settings'];
|
||||
host.appendChild(b.nodes['quick-close']);
|
||||
trigger.pointerenter({pointerType: 'touch'}); await tick();
|
||||
assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, 0);
|
||||
trigger.pointerenter({pointerType: 'mouse'}); await tick();
|
||||
assert.equal(host['data-quick'], 'true'); assert.equal(trigger['aria-expanded'], 'true');
|
||||
assert.equal(host.role, 'dialog'); assert.equal(b.nodes.terminal.hidden, false);
|
||||
const reads = b.calls.filter(c => c.url === '/api/settings/serial').length;
|
||||
trigger.focus(); b.click('quick-serial'); await tick();
|
||||
assert.equal(b.document.activeElement, b.nodes['quick-close']);
|
||||
assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, reads);
|
||||
trigger.pointerleave(); b.fire(250); assert.equal(host['data-quick'], 'true');
|
||||
assert.equal(b.calls.filter(c => c.url.includes('settings') && c.method === 'POST').length, 0);
|
||||
const ws = b.sockets[0]; ws.emit('open'); ws.emit('message', {data: JSON.stringify({type:'hello',clientId:8,writerId:8,role:'writer'})});
|
||||
b.terminals[0].input('not serial input'); assert.equal(ws.sent.length, 0);
|
||||
ws.emit('message', {data: Uint8Array.of(0,255).buffer}); assert.deepEqual(b.terminals[0].writes.at(-1), [0,255]);
|
||||
b.nodes['edit-baud'].value = '999';
|
||||
b.emit('keydown', {key:'Escape',preventDefault(){},stopPropagation(){}});
|
||||
assert.equal(host['data-quick'], 'false'); assert.equal(b.document.activeElement, trigger);
|
||||
assert.equal(b.nodes['edit-baud'].value, ''); assert.equal(trigger['aria-expanded'], 'false');
|
||||
trigger.pointerenter({pointerType:'mouse'}); await tick(); assert.equal(host['data-quick'],'false');
|
||||
b.terminals[0].input('x'); assert.equal(ws.sent.length, 1); assert.ok(!ws.closed);
|
||||
});
|
||||
await test('Quick adjacent trigger keyboard focus survives switching without focusing either terminal', async () => {
|
||||
const b = browser({role:'admin'}); b.start(); await tick();
|
||||
b.nodes['quick-network'].focus(); await tick();
|
||||
const term = b.terminals[0], before = term.focusCalls;
|
||||
b.nodes['quick-network'].focusout(); b.nodes['quick-serial'].focus(); await tick();
|
||||
assert.equal(b.document.activeElement,b.nodes['quick-serial']);
|
||||
assert.equal(b.nodes['quick-serial']['aria-expanded'],'true');
|
||||
assert.equal(b.nodes['quick-network']['aria-expanded'],'false');
|
||||
assert.equal(term.focusCalls,before);
|
||||
b.nodes['quick-serial'].focusout(); b.nodes['quick-network'].focus(); await tick();
|
||||
assert.equal(b.document.activeElement,b.nodes['quick-network']);
|
||||
assert.equal(term.focusCalls,before);
|
||||
});
|
||||
await test('Quick automatic dismissal restores Serial/Admin state without stealing outside focus; Close/Escape focus only trigger', async () => {
|
||||
for (const mode of ['serial','admin']) {
|
||||
const b = browser({role:'admin'}); b.start(); await tick();
|
||||
if (mode === 'admin') b.click('select-admin');
|
||||
const terminal = b.terminals[mode === 'admin' ? 1 : 0];
|
||||
b.nodes['quick-serial'].focus(); await tick();
|
||||
const before = b.terminals.map(t => t.focusCalls);
|
||||
b.nodes['quick-serial'].focusout(); b.nodes['connection-toggle'].focus();
|
||||
b.fire(250); await tick();
|
||||
assert.equal(b.document.activeElement,b.nodes['connection-toggle']);
|
||||
assert.deepEqual(b.terminals.map(t => t.focusCalls),before);
|
||||
assert.equal(b.nodes['serial-settings']['data-quick'],'false');
|
||||
assert.equal(b.nodes['select-' + mode]['aria-pressed'],'true');
|
||||
assert.equal(terminal.host.hidden,false);
|
||||
b.click('quick-serial'); await tick(); b.nodes['connection-toggle'].focus();
|
||||
b.emit('pointerdown',{target:b.nodes['connection-toggle']});
|
||||
assert.equal(b.document.activeElement,b.nodes['connection-toggle']);
|
||||
assert.deepEqual(b.terminals.map(t => t.focusCalls),before);
|
||||
for (const dismiss of ['close','escape']) {
|
||||
b.click('quick-serial'); await tick();
|
||||
if (dismiss === 'close') b.click('quick-close');
|
||||
else b.emit('keydown',{key:'Escape',preventDefault(){},stopPropagation(){}});
|
||||
assert.equal(b.document.activeElement,b.nodes['quick-serial']);
|
||||
assert.deepEqual(b.terminals.map(t => t.focusCalls),before);
|
||||
}
|
||||
assert.ok(b.sockets.every(s => !s.closed));
|
||||
}
|
||||
});
|
||||
await test('Quick dismissal fences late snapshots, hover bridge/outside click and expiry without replay', async () => {
|
||||
const b = browser({role:'admin'}); b.start(); await tick();
|
||||
const late = deferred(); b.queues['/api/settings/serial'].push(late.promise);
|
||||
b.nodes['quick-serial'].pointerenter({pointerType:'mouse'}); await tick();
|
||||
const request = b.calls.find(c => c.url === '/api/settings/serial');
|
||||
b.nodes['quick-serial'].pointerleave(); b.nodes['serial-settings'].hovered = true;
|
||||
b.fire(250); assert.equal(b.nodes['serial-settings']['data-quick'], 'true');
|
||||
b.emit('pointerdown', {target:b.nodes['connection-toggle']});
|
||||
assert.ok(request.signal.aborted); late.resolve(json(serialSettings())); await tick();
|
||||
assert.equal(b.nodes['serial-settings']['data-quick'], 'false'); assert.equal(b.nodes['edit-baud'].value, '');
|
||||
b.click('quick-serial'); await tick(); b.emit('pagehide');
|
||||
assert.equal(b.nodes['serial-settings']['data-quick'], 'false'); assert.ok(b.sockets.every(s => s.closed));
|
||||
const count = b.calls.length; b.click('quick-serial'); await tick(); assert.equal(b.calls.length,count);
|
||||
});
|
||||
await test('Quick full-page promotion preserves the sole draft/controller and full-page hover cannot discard drafts', async () => {
|
||||
const b = browser({role:'admin'}); b.start(); await tick(); b.click('quick-serial'); await tick();
|
||||
b.nodes['edit-baud'].value = '115200'; const reads = b.calls.length;
|
||||
b.nodes['quick-full'].click({preventDefault(){}});
|
||||
assert.equal(b.nodes['serial-settings']['data-quick'], 'false'); assert.equal(b.nodes['edit-baud'].value, '115200');
|
||||
b.nodes['quick-network'].pointerenter({pointerType:'mouse'}); await tick();
|
||||
assert.equal(b.calls.length,reads); assert.equal(b.nodes['edit-baud'].value, '115200');
|
||||
const u = await connected(); u.click('quick-serial'); u.nodes['quick-network'].focus(); await tick();
|
||||
assert.ok(!u.calls.some(c => c.url.startsWith('/api/settings/')));
|
||||
});
|
||||
await test('Quick Serial shares validation, explicit apply/save and pending result recovery without replay', async () => {
|
||||
const b = browser({role:'admin'}); b.start(); await tick(); b.click('quick-serial'); await tick();
|
||||
b.nodes['edit-baud'].value = 'bad'; b.click('serial-apply'); await tick();
|
||||
assert.ok(!b.calls.some(c => c.url.endsWith('serial-operation')));
|
||||
b.nodes['edit-baud'].value = '115200';
|
||||
b.queues['/api/settings/serial-operation'].push(new Response(JSON.stringify({id:4,action:'apply',state:'pending'}),{status:202}));
|
||||
b.click('serial-apply'); await tick(); b.click('quick-close'); await tick(); b.click('quick-serial'); await tick();
|
||||
assert.equal(b.calls.filter(c => c.url.endsWith('serial-operation') && c.method === 'POST').length,1);
|
||||
assert.ok(b.nodes['serial-apply'].disabled); assert.match(b.nodes['serial-operation-detail'].textContent,/uncertain|unknown|pending/);
|
||||
assert.ok(![...b.timers.values()].some(t => t.ms === 1000));
|
||||
});
|
||||
await test('Quick Save sends only the explicit persistence action, and active editor focus resists unrelated hover', async () => {
|
||||
const b = browser({role:'admin'}); b.start(); await tick(); b.click('quick-serial'); await tick();
|
||||
const host = b.nodes['serial-settings']; host.appendChild(b.nodes['edit-baud']);
|
||||
b.nodes['edit-baud'].value = '9600'; b.nodes['edit-baud'].focus();
|
||||
b.nodes['quick-network'].pointerenter({pointerType:'mouse'}); await tick();
|
||||
assert.equal(b.nodes['quick-serial']['aria-expanded'],'true'); assert.equal(b.nodes['edit-baud'].value,'9600');
|
||||
b.queues['/api/settings/serial-operation'].push(new Response(JSON.stringify({id:5,action:'save',state:'pending'}),{status:202}));
|
||||
b.click('serial-save'); await tick();
|
||||
const posts = b.calls.filter(c => c.url.endsWith('serial-operation') && c.method === 'POST');
|
||||
assert.equal(posts.length,1); assert.deepEqual(JSON.parse(posts[0].body),{action:'save'});
|
||||
b.nodes['quick-full'].click({preventDefault(){}}); await tick();
|
||||
assert.ok([...b.timers.values()].some(t => t.ms === 1000));
|
||||
assert.equal(b.calls.filter(c => c.url.endsWith('serial-operation') && c.method === 'POST').length,1);
|
||||
});
|
||||
await test('bootstrap, CSRF, bounded expiry safe text, serial protocol and disconnect pause', async () => {
|
||||
const b = await connected();
|
||||
assert.equal(b.calls[0].url, '/api/session');
|
||||
|
||||
@@ -45,6 +45,17 @@ def check_layout(html):
|
||||
if cls in classes(node):
|
||||
return node
|
||||
raise AssertionError(cls)
|
||||
for domain in ('serial', 'network'):
|
||||
trigger = ids['quick-' + domain]
|
||||
assert trigger['tag'] == 'button' and trigger['attrs']['type'] == 'button'
|
||||
assert trigger['attrs']['aria-controls'] == 'serial-settings'
|
||||
assert trigger['attrs']['aria-expanded'] == 'false'
|
||||
assert trigger['attrs']['aria-haspopup'] == 'dialog'
|
||||
assert trigger['attrs']['aria-label'] and 'disabled' in trigger['attrs']
|
||||
assert ids['quick-full']['tag'] == 'a' and ids['quick-full']['attrs']['href'] == '#serial-settings'
|
||||
assert 'hidden' in ids['quick-header']['attrs']
|
||||
assert ids['network-password']['parent'] is ids['network-password-label']
|
||||
assert ids['network-password-mode']['parent'] is ids['network-password-mode-label']
|
||||
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values', 'broker-values'):
|
||||
assert ids[ident]['tag'] == 'dl'
|
||||
assert 'settings-values' in classes(ids[ident])
|
||||
@@ -103,8 +114,10 @@ def check_browser_layout(html, tmp, executable):
|
||||
fixture = re.sub(r'<link\b[^>]*>|<img\b[^>]*>', '', fixture)
|
||||
probe = r'''
|
||||
const cases = [];
|
||||
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) {
|
||||
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = '900px';
|
||||
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) for (const quick of [false, true]) {
|
||||
if (quick && !['serial-settings-content', 'network-settings'].includes(view)) continue;
|
||||
const height = quick ? 360 : 900;
|
||||
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = height + 'px';
|
||||
cases.push(new Promise(resolve => {
|
||||
frame.onload = () => {
|
||||
const d = frame.contentDocument, win = frame.contentWindow;
|
||||
@@ -122,6 +135,17 @@ def check_browser_layout(html, tmp, executable):
|
||||
});
|
||||
section.querySelectorAll('input:not([type=checkbox]),textarea').forEach(n => n.value = 'x'.repeat(96));
|
||||
const errors = [];
|
||||
if (quick) {
|
||||
const host = d.getElementById('serial-settings'); host.dataset.quick = 'true';
|
||||
d.getElementById('quick-header').hidden = false;
|
||||
const rect = host.getBoundingClientRect();
|
||||
if (rect.left < 0 || rect.top < 0 || rect.right > width || rect.bottom > height) errors.push('popover viewport overflow');
|
||||
if (host.scrollWidth > host.clientWidth + 1) errors.push('popover horizontal overflow');
|
||||
if (win.getComputedStyle(host).overflowY !== 'auto') errors.push('popover not scrollable');
|
||||
for (const id of ['settings-navigation', 'network-password-mode-label', 'network-password-label', 'network-password-status']) {
|
||||
if (d.getElementById(id).getBoundingClientRect().width) errors.push('quick hidden content:' + id);
|
||||
}
|
||||
}
|
||||
for (const node of section.querySelectorAll('dl,input,select,textarea,.settings-edit,.serial-edit,.serial-actions')) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (!rect.width) continue;
|
||||
@@ -132,7 +156,7 @@ def check_browser_layout(html, tmp, executable):
|
||||
}
|
||||
// Measure glyph ranges, not the full-width grid cell or textContent.
|
||||
// The normal-whitespace control proves the pair detects collapsing.
|
||||
for (const dd of section.querySelectorAll('dl dd:first-of-type')) {
|
||||
for (const dd of quick ? [] : section.querySelectorAll('dl dd:first-of-type')) {
|
||||
const original = dd.textContent;
|
||||
const measureSSID = spaces => {
|
||||
dd.textContent = 'SSID: ' + JSON.stringify('office' + ' '.repeat(spaces) + 'wifi');
|
||||
@@ -147,7 +171,7 @@ def check_browser_layout(html, tmp, executable):
|
||||
if (Math.abs(measureSSID(2) - measureSSID(1)) > 0.1) errors.push('invalid whitespace control');
|
||||
dd.style.removeProperty('white-space'); dd.textContent = original;
|
||||
}
|
||||
resolve({width, view, errors});
|
||||
resolve({width, height, view, quick, errors});
|
||||
};
|
||||
}));
|
||||
frame.srcdoc = FIXTURE; document.body.append(frame);
|
||||
@@ -166,6 +190,6 @@ def check_browser_layout(html, tmp, executable):
|
||||
assert result.returncode == 0, result.stderr
|
||||
parsed = Document(result.stdout)
|
||||
results = json.loads(parsed.ids['layout-results']['text'])
|
||||
assert len(results) == 12
|
||||
assert len(results) == 18
|
||||
assert all(not case['errors'] for case in results), results
|
||||
print('PASS Chromium layout: all four settings views at 320/600/1200px; bounded controls, summaries, inline checkboxes and rendered consecutive-space distinction (fixture data, not live app)')
|
||||
print('PASS Chromium layout: four full views plus Serial/Wi-Fi popovers at 320/600/1200px (quick height360); viewport/overflow/secret exclusion and full-page whitespace checks (fixtures, not live app)')
|
||||
@@ -28,6 +28,34 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
||||
b.queues[operation].push(reply(42, state, action, 200, error)); b.queues[path].push(json(value));
|
||||
b.fire(1000); await tick();
|
||||
}
|
||||
await test('Quick Wi-Fi uses shared strict snapshots/nonsecret edits and explicit save, never cached passwords', async () => {
|
||||
const b = await adminBrowser(); b.queues[path].push(json(fixture()));
|
||||
b.click('quick-network'); await tick();
|
||||
assert.equal(b.nodes['serial-settings']['data-quick'], 'true');
|
||||
assert.equal(reads(b).length,1); assert.equal(posts(b).length,0); clean(b);
|
||||
assert.ok(n(b,'password-mode').disabled);
|
||||
target(b,'0'); assert.equal(posts(b).length,0); clean(b);
|
||||
input(b,'ssid','new network');
|
||||
b.queues[operation].push(ack('profile-patch')); b.click('network-apply'); await tick();
|
||||
assert.equal(posts(b).length,1);
|
||||
const body = JSON.parse(posts(b)[0].body); assert.equal(body.action,'profile-patch');
|
||||
assert.ok(!Object.hasOwn(body,'password')); clean(b);
|
||||
b.click('quick-close'); await tick(); assert.equal(n(b,'ssid').value,''); clean(b);
|
||||
b.queues[path].push(json(fixture())); b.click('quick-network'); await tick();
|
||||
assert.equal(posts(b).length,1); assert.ok(n(b,'apply').disabled);
|
||||
assert.match(n(b,'operation-detail').textContent,/pending|unknown|uncertain/);
|
||||
});
|
||||
await test('Quick Network rejects extra secret fields, fences401 and keeps full-page secret draft out of hover', async () => {
|
||||
const b = await open(); secret(b); const count = b.calls.length;
|
||||
b.nodes['quick-serial'].pointerenter({pointerType:'mouse'}); await tick();
|
||||
assert.equal(b.calls.length,count); assert.equal(n(b,'password').value,'a safe PSK');
|
||||
b.click('select-serial'); clean(b);
|
||||
b.queues[path].push(json({...fixture(), password:'SECRET'})); b.click('quick-network'); await tick();
|
||||
assert.equal(n(b,'edit').hidden,true); safe(b); clean(b);
|
||||
b.click('quick-close'); b.queues[path].push(failure(401)); b.click('quick-network'); await tick();
|
||||
assert.deepEqual(b.redirects,['/login']); assert.equal(b.nodes['serial-settings']['data-quick'],'false'); clean(b);
|
||||
assert.ok(b.sockets.every(s => s.closed));
|
||||
});
|
||||
await test('Network authored HTML has actual AP/four profiles/mDNS controls and truthful persistence/recovery policy', async () => {
|
||||
for (const id of ['settings-network','network-target','network-ssid','network-ssid-mode','network-apply','network-result','network-wifi-save','network-wifi-load','network-start','network-stop','network-reconnect','network-next-profile','network-mdns-set','network-mdns-save','network-mdns-load','network-mdns-defaults']) assert.ok(html.includes('id="' + id + '"'), id);
|
||||
for (let i = 0; i < 4; i++) assert.ok(html.includes('value="' + i + '">STA profile ' + i));
|
||||
|
||||
Reference in New Issue
Block a user