Add Broker client and writer quick dialogs

This commit is contained in:
2026-09-13 14:40:51 +02:00
parent 9f6ebf2053
commit 7ccc8799e9
9 changed files with 274 additions and 32 deletions
+1 -1
View File
@@ -245,7 +245,7 @@ Bounds:256-byte/four-receive request,128-byte snapshot,96-byte result, one slot;
The broker copies compact client rows and writer/lease generation together under its existing mutex with zero wait. Conditional assignment compares generation and connected non-reused target under the same force-writer lock before any effect. Lease grant/release/revoke events advance a separate saturating32-bit generation before delivery attempts, including same-writer ABA; counter clears and dropped events cannot invalidate this fence. UINT32_MAX disables conditional assignment, not ordinary transport/console recovery. Client IDs retain three slot/29 generation bits, but exhausted slots now retire until reboot rather than reusing IDs. Reboot invalidates originating web sessions. Existing unconditional force APIs and one-writer/multiple-observer policy remain unchanged. The broker copies compact client rows and writer/lease generation together under its existing mutex with zero wait. Conditional assignment compares generation and connected non-reused target under the same force-writer lock before any effect. Lease grant/release/revoke events advance a separate saturating32-bit generation before delivery attempts, including same-writer ABA; counter clears and dropped events cannot invalidate this fence. UINT32_MAX disables conditional assignment, not ordinary transport/console recovery. Client IDs retain three slot/29 generation bits, but exhausted slots now retire until reboot rather than reusing IDs. Reboot invalidates originating web sessions. Existing unconditional force APIs and one-writer/multiple-observer policy remain unchanged.
Browser Settings/Broker shows safe ID/type/name/role/pending/HWM/drop rows and clears selection on Refresh. Native confirmation captures exact target/generation before session revalidation. Viewing/selecting never mutates; completion checks are bounded to10 one-second GETs/15seconds, no mutation replay. There are no contextual popovers or diagnostic/service controls. Complete contracts, wrap analysis, evidence and pending parent/target review: `docs/phase8d16_implementation.md`. Browser Settings/Broker shows safe ID/type/name/role/pending/HWM/drop rows. **8D.18** reuses this controller in8D.17's sole nonmodal quick host for admin-only Broker clients/Active writer entrances; no second DOM/draft/backend. Contextual snapshots refresh5seconds after successful completion with one active Broker controller and5-second whole-read deadline, stopping on error/uncertainty/departure. Explicit selection retains its identity and lease version across refresh; stale/absent latches cannot be cleared by later matching snapshots, only deliberate reselection. Native confirmation captures exact target/generation before session revalidation. Viewing/selecting never mutates; completion checks remain bounded to10 one-second GETs/15seconds, no mutation replay. New contextual triggers never navigate away from full-page drafts; promotion preserves selection/controller, stops live scheduling. Both terminals receive with input isolated; focused controls use guarded aria-disabled state to avoid focus loss during refresh. No diagnostic/service controls. Backend/wrap contract: `docs/phase8d16_implementation.md`; current UI/test/target limits: `docs/phase8d18_implementation.md`.
## Local UI and hardware boundaries ## Local UI and hardware boundaries
+2
View File
@@ -61,6 +61,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the
## Web and WebSocket serial ## Web and WebSocket serial
- **8D.18 client/writer contexts (2026-09-13):** `web_ui.c` extends8D.17's sole quick host with admin-only Broker clients/Active writer entrances to the existing Broker controller/native confirmation. One5-second-after-success live timer and5-second whole-read deadline; explicit identity/lease token retained across refresh, sticky stale/absence latches prevent rebasing/resurrection. Full-page drafts untouched by new triggers; focused controls retain focus with guarded aria-disabled state. No backend/policy/assets/CPU/transport changes. `tests/web_ui_session/{broker.cjs,layout.py}`:135 UI groups+renderer/HTML/CSP; broad broker/auth/lifecycle/transport regressions PASS. Baseline100,300/1,788,661 → final100,300/1,793,301 RAM/flash (+0/+4,640 B), CPU160 verified. Independent parent review and target sign-off pending. Exact contracts/tests/limits/checklist: `docs/phase8d18_implementation.md`.
- **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.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.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`.
+2
View File
@@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do
## Development state ## Development state
- **8D.18 implemented (2026-09-13), user-authorized; host/build verified, independently reviewed; target sign-off pending:** UI-only Broker clients/Active writer contexts reuse8D.16 snapshot/lease fence/operation controller and8D.17 single host/native confirmation/dismissal focus fix.5-second-after-success single-flight live refresh with5-second whole-read deadline; errors/timeouts stop, no auto error/mutation retry. Explicit selection identity+lease token is never rebased; stale/absent latches prevent resurrection, consumed selection requires explicit reselection. New triggers preserve full-page Serial/account/Network drafts even on activation; quick promotion preserves selection/controller and stops live timer. Focused controls use aria-disabled plus existing handler guards rather than native focus loss; previous terminals keep receiving with input isolated. Initial worktree clean. PASS135 UI groups+renderer/HTML/CSP, broker management/wrap, cookie Broker+base/all existing variants, lifecycle25, dispatcher/canonical accounts/Network, parser294, transports/tickets/store, idle/diagnostics/performance/security/login and diff check. Broad tests preceded final focus refinement; full UI/build rerun afterward. Independent reviewer found no confirmed actionable defects and reran UI135+renderer/HTML/CSP, canonical broker management/wrap, cookie Broker and diff checks PASS. Parent final pio confirmation PASS6.89s100,300/1,793,301 B; diff check PASS. Native picker during live updates and actual blank/reselect remain browser-validation gaps, not proven defects. Baseline pio7.02s100,300 RAM/1,788,661 flash; final21.52s100,300/1,793,301 (+0/+4,640 B). CPU160 defaults/active/generated verified, combined WS send unchanged. No backend/API/policy/assets/config/dependencies/icons/8D.15/19/later/upload/erase/commit/SDK changes. No real-browser geometry/accessibility or device tests this phase; no reserve/M3 claim. Exact contract, test-development corrections and pending parent/target checklist: `docs/phase8d18_implementation.md`. Stop before any separately authorized next phase.
- **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 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.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.
+2
View File
@@ -66,6 +66,8 @@ Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs
**Wrap contract:** Three-slot-bit/29-generation-bit broker IDs now retire exhausted slots instead of wrapping. A32-bit lease generation saturates at UINT32_MAX, survives counter clear, and advances for each grant/release/revoke emission before advisory queue delivery. Forced transfer can advance twice; it is an opaque version, not a count. Saturation rejects typed assignment but never prevents normal release/disconnect/request or recovery force. Future writer transitions must preserve the central event-emission invariant. Reboot resets broker state but invalidates web sessions, so authorized old browser work cannot span boots. **Wrap contract:** Three-slot-bit/29-generation-bit broker IDs now retire exhausted slots instead of wrapping. A32-bit lease generation saturates at UINT32_MAX, survives counter clear, and advances for each grant/release/revoke emission before advisory queue delivery. Forced transfer can advance twice; it is an opaque version, not a count. Saturation rejects typed assignment but never prevents normal release/disconnect/request or recovery force. Future writer transitions must preserve the central event-emission invariant. Reboot resets broker state but invalidates web sessions, so authorized old browser work cannot span boots.
**8D.18 browser selection contract:** Live refresh preserves explicit target identity and its original lease token, not just the select value. Lease/identity mismatch, absence or failed read latches invalidation; later matching snapshots cannot resurrect validity or silently reselect a missing target. Only explicit reselection captures a new token, and transfer still requires separate confirmation. One shared quick/full controller fences reads/operations; contextual activation never destroys full-page drafts. Focused controls remain focusable with aria-disabled while independent action guards reject unavailable work. See `docs/phase8d18_implementation.md` for bounds and test limits.
**Consequence:** Never implement snapshot-check-unlock-force, compare only current writer ID, or renew a confirmation implicitly during Refresh. UI must require explicit target selection and confirmation, retain uncertain-outcome handling and never retry mutations automatically. Bounded login-isolated result slots and the existing dispatcher remain the typed HTTP boundary. Details/tests: `docs/phase8d16_implementation.md`, `src/session_broker.{c,h}`, `src/web_broker_settings.{c,h}`. **Consequence:** Never implement snapshot-check-unlock-force, compare only current writer ID, or renew a confirmation implicitly during Refresh. UI must require explicit target selection and confirmation, retain uncertain-outcome handling and never retry mutations automatically. Bounded login-isolated result slots and the existing dispatcher remain the typed HTTP boundary. Details/tests: `docs/phase8d16_implementation.md`, `src/session_broker.{c,h}`, `src/web_broker_settings.{c,h}`.
## UART0 is the physical recovery authority ## UART0 is the physical recovery authority
+113
View File
@@ -0,0 +1,113 @@
# Phase 8D.18 — Client/writer contextual dialogs
## Status and scope (2026-09-13)
User-authorized 8D.18 implementation complete; host tests and bounded firmware builds pass. **Independent review complete with no confirmed actionable defects; target/user sign-off remains pending.** Reviewer independently reran135 UI groups+renderer/HTML/CSP, canonical broker diagnostics/management/wrap, cookie Broker and diff checks PASS. Parent final build confirmation PASS6.89s,100,300 B RAM/1,793,301 B flash; diff check PASS. Native picker behavior during live option updates and real blank-to-target reselection remain target checks, not established by direct Node change events. No M3 or memory-reserve approval. Initial read-only Git status was empty; no preexisting work was removed.
Only `src/web_ui.c`, `tests/web_ui_session/{broker.cjs,layout.py}` and phase/agent documentation changed. No backend, writer policy, API, dispatcher, task, queue, stack-size, schema, dependency, icon, generated asset, SDK or configuration changes. No 8D.19/later or removed 8D.15 diagnostics. No upload, erase, commit or branch creation. The signed-off CPU160 combined binary WebSocket send remains untouched.
## Implementation plan and completed source set
1. Read code-map, current-state, relevant architecture/design decisions and exact 8D.18 row; inspect 8D.17's single quick host/focus correction and 8D.16's typed Broker controller and lease-fence contract.
2. Establish clean-worktree/bounded-build baseline. Reuse the existing Broker snapshot, session validation and operation controller, not a second backend or UI owner.
3. Extend the existing status-trigger/nonmodal quick host to Broker clients and Active writer. Preserve terminal receive/input isolation, dismissal focus semantics and full-page draft ownership.
4. Introduce one explicit selection record and single-flight, deadline-bounded live snapshot refresh. Never renew selection tokens implicitly; latch invalidation and retain visible stale-result/uncertainty messages.
5. Extend emitted-production-JS, parsed HTML and existing broker/auth/lifecycle regressions; build, verify CPU160 and record exact limits and pending target checks.
All five steps completed. Shared firmware APIs already supplied the required correctness boundary; no owner adaptation was necessary.
## UI and ownership contract
- Broker clients and Active writer status values are native labeled buttons, initially disabled, enabled only after validated admin session adoption. Ordinary users retain ordinary status values, cannot open management via hover/focus/click, and receive unchanged server-side denial on protected endpoints. No management read is issued for ordinary users.
- Both contextual entrances disclose the **same existing Broker form** in the sole `serial-settings` host. They have distinct live-client/writer accessible titles, share the client list/selection/explicit assignment controls, and use the existing browser-native confirmation dialog for the actual transfer. No second draft, client list, operation slot or DOM clone.
- Mouse hover and keyboard focus disclose without mutation; native click/Enter/Space/tap activation focuses Close. Touch pointer-enter alone does not disclose. `aria-controls`, `aria-expanded`, `aria-haspopup=dialog`, accessible names, visible focus, nonmodal dialog semantics, native select/actions, Close and full-page link remain available. No focus trap.
- The 8D.17 250ms pointer/focus bridge, focused-editor protection, Escape/outside dismissal and stationary-pointer suppression apply to both new triggers. Dismissal/adjacent-trigger switching restores logical terminal selection without calling xterm focus; Close/Escape focus only the trigger. Both terminals continue receiving bounded binary-transparent output, with input disabled while the settings host is selected. No socket/client/lease mutation on UI navigation.
- Full-page Settings retains its sole DOM/draft owner. **Neither hover, focus nor explicit activation of the new Broker/writer triggers changes a full-page domain or its draft**, including transient account/Network password input. Explicit activation focuses the existing domain navigation; trigger help explains using Broker navigation or returning to a terminal first. Existing Serial/Wi-Fi explicit full-page navigation is unchanged. Switching between quick views is deliberate context departure and retains existing draft cleanup rules.
- Promotion through Open full settings page retains the same Broker selection/controller without refetch, abort, POST or replay; it stops contextual live scheduling. Full Broker remains manually refreshed. Closing/switching clears selection, aborts/fences browser reads/results and stops timers, but never claims cancellation of admitted firmware work.
- Periodic button state updates use `aria-disabled` and preserve a currently focused control instead of natively disabling it and dropping keyboard focus. Nonfocused unavailable controls remain natively disabled. Independent request/action guards enforce availability, including clicks delivered to an aria-disabled focused button. Stale assignment attempts display a rejection without a POST.
## Live refresh and selection fences
- Immediate opening read, then **one next snapshot refresh 5 seconds after successful completion**, only while a Broker contextual view is open. Each cycle revalidates the current login, then uses the existing bodyless GET `/api/settings/broker` (strict 2048-byte/eight-client schema). This is not an additional aggregate status backend.
- At most one active Broker snapshot/operation controller. Manual reads, repeated opening and result/assignment work cannot overlap that controller. A **5-second whole snapshot-cycle deadline** covers session validation, fetch and body handling; timeout aborts, clears controller ownership and invalidates selection. The deadline is cleared on completion/abort. Error/timeout stops automatic live refresh until explicit Refresh/reopen; no automatic error retry.
- No timer while an operation/result is active or its outcome is pending/unknown. Known terminal operation results use the existing refresh path. Polling frequency is not a hard wall-clock freshness guarantee: background timer throttling, HTTPD and network delays remain possible.
- Explicit select change captures target ID, type/name identity and the current opaque lease generation. Reorder/counter-only refresh retains that choice and token. A lease version or identity mismatch latches `stale`; a failed/invalid/timed-out snapshot also latches it. Later matching data cannot clear the latch. A missing target clears the displayed select and latches absence so its later appearance cannot silently reselect it.
- Refresh never authorizes a new transfer. The admin must deliberately choose the blank option then the target again to reselect an otherwise still-displayed stale target. Assignment stays blocked for missing/stale/exhausted/current-writer targets. Submission consumes validity; operation completion refresh cannot re-arm it.
- Confirmation captures exact target/version **before** asynchronous session revalidation and submits once through `/api/settings/broker-operation`. Concurrent real lease changes after the browser snapshot are rejected by the unchanged atomic 8D.16 broker fence. Existing conflict result text visibly states no lease change by that operation. No check-unlock-force, lease-policy change or automatic mutation retry.
- Existing bounded result checks remain at most 10 one-second GETs/15 seconds; lost ACK, replacement, invalid response, expiry and late responses retain uncertainty and explicit Check Result recovery. Browser abort is not server cancellation, successful execution is not a guarantee the writer remains current, and latest login-bound results are not durable/idempotent records.
- Session/work generation, request-controller identity and API abort/current checks fence late responses, including late 401 after navigation. Expiry/logout/pagehide invalidate views and close both routes through existing cleanup. New views cannot adopt stale selection/snapshot/result state.
## Executed validation
All commands below passed, bounded at 120 seconds per tool invocation; none timed out. Broad suites ran after the initial implementation; the final focused-control refinement was followed by another complete UI suite and firmware build.
```sh
python3 tests/web_ui_session/run.py
python3 tests/session_broker_diagnostics/run.py
python3 tests/web_cookie_auth/run.py --broker
python3 tests/admin_console_boundary/run.py
python3 tests/web_admin_transport/server_lifecycle.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_auth_parse/run.py
python3 tests/web_session_store/run.py
python3 tests/web_session_store/run.py --serial
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/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 **135 behavior groups** (128 baseline +7 contextual groups), plus actual C rendering/header/error checks, parsed unique HTML ownership/native labels and exact unchanged inline-loader CSP. New groups cover:
- ordinary/admin pointer/focus/touch disclosure, explicit close/Escape/outside focus, Serial/Admin receive and input isolation;
- deferred single-flight reads, reordered/counter-updated clients, preserved explicit choice, stale lease/identity latches and missing/returning target non-resurrection;
- focused action/select accessibility during refresh and stale rejection without focus theft or POST;
- malformed/503 snapshots, stalled session/snapshot deadlines, explicit recovery and ignored late snapshot data;
- full-page Serial/account/Network drafts, adjacent Serial→writer keyboard focus and promotion without reads/reset;
- captured confirmation through delayed session validation, visible conflict, lost acknowledgement, dismissal/reopen without replay;
- late read/POST/result401 after adjacent quick navigation, current expiry/pagehide/principal replacement/401 cleanup.
Existing canonical broker management/wrap tests verify real lease ABA/target-generation rejection; cookie Broker tests verify admin/session/Origin/CSRF/bounds/result isolation; lifecycle25 and unchanged dispatcher capacity tests pass. Parser294, transports/tickets, session binding, Network/accounts, idle, diagnostics, combined-send performance, security and login regressions also pass.
Test-development failures were corrected rather than hidden: one old assertion expected refresh to clear the choice; new refresh fixtures initially fired ordinary status's 5-second interval instead of the dedicated one-shot, and a delayed session fixture used the wrong username. The final harness explicitly distinguishes interval versus one-shot timers and uses the actual same principal. Production strict identity rejection was correct.
**Limits:** Node uses deterministic DOM/fetch/clock/socket doubles, not real native browser keyboard event defaults, focus engine, screen reader, mobile picker or touch hardware. Parsed native button semantics plus exercised click/focus/keydown/pointer paths do not establish real-browser accessibility sign-off. No optional Chromium geometry run in this phase; 8D.17 recorded sandbox launch restrictions. No on-device concurrency, actual TLS timing, heap/reserve, HTTPD/dispatcher stack or full-mix target measurement. These results do not reopen or expand the signed-off CPU160 throughput evidence.
## Build and resources
| Build | RAM | Flash | Time |
|---|---:|---:|---:|
| Actual pre-edit baseline | 100,300 B | 1,788,661 B | 7.02 s |
| Initial implementation | 100,300 B | 1,793,077 B | 21.99 s |
| Final focused-control refinement | 100,300 B | 1,793,301 B | 21.52 s |
| Final delta from baseline | **0 B** | **+4,640 B** | incremental times not comparable |
CPU160 verified by targeted inspection of `sdkconfig.defaults`, active `sdkconfig.esp32-s3-devkitc-1-n16r8` and generated `.pio/build/esp32-s3-devkitc-1-n16r8/config/sdkconfig.h`. PlatformIO's generic board banner still says240MHz; actual config says160. No edits to these files. 33 handlers/six sockets, existing tasks/stacks/queue capacity and broker/WebSocket buffers are unchanged. Browser additions are one bounded selection record, one live scheduling timer or active read deadline, and two trigger listener sets; existing rows/controllers are reused. Regular live reads add HTTPS load while shown; runtime headroom remains unmeasured.
## Pending parent and target checklist
- [x] Independent review of source, tests, ownership, stale selection and request/focus fencing; no confirmed actionable defects.
- [ ] Desktop mouse hover bridge, scrollable client list, adjacent triggers, outside dismissal and no unwanted reopening after Escape.
- [ ] Keyboard Tab/Shift-Tab, native Enter/Space, select navigation, blank/reselect, focused action refresh/stale invalidation, native confirmation Cancel/Accept and Escape; no terminal key leakage. Screen-reader titles/expanded/aria-disabled/status announcements.
- [ ] Touch tap/scroll/select/confirm/outside tap, rotation/zoom/software keyboard and narrow/short viewport geometry; real native-picker behavior during refresh.
- [ ] Every full settings draft survives Broker/writer activation; sole-host promotion and Serial/Wi-Fi quick switching behave as documented.
- [ ] USB/SSH/two-browser clients join/leave/reorder; concurrent shell/tab writer changes and same-writer release/reacquire reject stale confirmation visibly; explicit reselection does not assign until separately confirmed.
- [ ] Delayed/lost snapshot/ACK/results, actual network loss, expiry/logout/bfcache and identity replacement: no late state restoration, mutation replay or cancellation claims; Check Result recovery remains available.
- [ ] Both browser terminals continue receiving with UI open; correct input focus after dismissal; physical UART0 administration/native USB recovery remain usable.
- [ ] Full mix at signed-off160MHz/230400 baud with live refresh: capture drops/latency/heap and HTTPD/dispatcher margins, without inferring numeric reserve approval.
No target checklist item or independent-review item was signed off by this implementation. Stop before 8D.19 or any other separately authorized phase.
+1 -1
View File
@@ -189,7 +189,7 @@ Typed operations must preserve subsystem owner/lock/persistence contracts and co
| **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.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.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** | **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.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.18 — Client/writer contextual dialogs** | **Implemented, host/build verified; independent parent review and target sign-off pending.** UI-only reuse of8D.16 and8D.17's single host for live client popover and confirmed Active writer dialog. [Contract/tests/checklist](phase8d18_implementation.md). | Native pointer/keyboard/touch entrances; single-flight5-second live refresh/deadline, explicit selection preserved without lease-token renewal, sticky stale/absent rejection, full-page draft protection and focus-safe updates. Ordinary users retain only ordinary status.135 UI groups plus broad broker/auth/lifecycle regressions pass; real browser/device checks pending. No new writer policy/backend/icons/8D.19/later. |
| **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.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. | | **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. |
| **8D.21 — Security/danger-zone settings** | Carefully separated HTTPS/SSH identity rotation/reset and any explicitly retained recovery-secret operation through existing security APIs. Enumerate allowed operations first; split HTTPS and SSH work into separate requests if both need owner changes. | Confirmation, secret-safe one-time responses, no routine private-key export, expected trust/fingerprint changes, bounded self-disconnect, no accidental configuration wipe. Bootstrap/unavailable-database recovery remain UART0-only. NVS encryption, secure boot, OTA, and new factory-reset semantics stay out of scope. | | **8D.21 — Security/danger-zone settings** | Carefully separated HTTPS/SSH identity rotation/reset and any explicitly retained recovery-secret operation through existing security APIs. Enumerate allowed operations first; split HTTPS and SSH work into separate requests if both need owner changes. | Confirmation, secret-safe one-time responses, no routine private-key export, expected trust/fingerprint changes, bounded self-disconnect, no accidental configuration wipe. Bootstrap/unavailable-database recovery remain UART0-only. NVS encryption, secure boot, OTA, and new factory-reset semantics stay out of scope. |
+60 -26
View File
@@ -153,9 +153,9 @@ static const char s_index_html[] =
"<div class=\"status-item\"><span class=\"label\">Broker client</span>" "<div class=\"status-item\"><span class=\"label\">Broker client</span>"
"<span id=\"client-id\" class=\"value\">—</span></div>\n" "<span id=\"client-id\" class=\"value\">—</span></div>\n"
"<div class=\"status-item\"><span class=\"label\">Active writer</span>" "<div class=\"status-item\"><span class=\"label\">Active writer</span>"
"<span id=\"writer-id\" class=\"value\">None</span></div>\n" "<button type=\"button\" id=\"quick-writer\" class=\"quick-trigger\" aria-label=\"Active writer management\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"writer-id\" class=\"value\">None</span></button></div>\n"
"<div class=\"status-item\"><span class=\"label\">Broker clients</span>" "<div class=\"status-item\"><span class=\"label\">Broker clients</span>"
"<span id=\"broker-clients\" class=\"value\">—</span></div>\n" "<button type=\"button\" id=\"quick-broker\" class=\"quick-trigger\" aria-label=\"Live broker clients\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"broker-clients\" class=\"value\">—</span></button></div>\n"
"<div class=\"status-item wide\"><span class=\"label\">Wi-Fi</span>" "<div class=\"status-item wide\"><span class=\"label\">Wi-Fi</span>"
"<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" "<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>" "<div class=\"status-item wide\"><span class=\"label\">Serial</span>"
@@ -186,12 +186,12 @@ static const char s_index_html[] =
"<div id=\"terminal\" class=\"terminal-host\"></div>\n" "<div id=\"terminal\" class=\"terminal-host\"></div>\n"
"<div id=\"admin-terminal\" class=\"terminal-host\" hidden></div>\n" "<div id=\"admin-terminal\" class=\"terminal-host\" hidden></div>\n"
"<section id=\"serial-settings\" class=\"settings-page\" aria-label=\"Serial settings\" hidden>" "<section id=\"serial-settings\" class=\"settings-page\" aria-label=\"Serial settings\" hidden>"
"<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=\"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 id=\"quick-help\" 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>" "<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-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-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>" "<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>"
"<div id=\"broker-settings\" hidden><h2>Broker clients and writer</h2><p class=\"connection-detail\">One writer, multiple isolated observers. Viewing, refreshing and selecting do not change the lease or either terminal. Assignment revokes the previous writer, without recalling bytes already accepted by UART. Any intervening lease transition rejects stale confirmation, even release and reacquire by the same writer.</p><p class=\"connection-detail\">Pending and high-water are bounded output bytes; dropped counts cover this connection or the last shell counter clear. No UART data is consumed. Refresh clears selection. No persistence or disconnect controls.</p><button id=\"broker-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"broker-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"broker-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Assign writer to<select id=\"broker-target\"><option value=\"\">Select a connected client</option><option id=\"broker-option-0\" hidden disabled></option><option id=\"broker-option-1\" hidden disabled></option><option id=\"broker-option-2\" hidden disabled></option><option id=\"broker-option-3\" hidden disabled></option><option id=\"broker-option-4\" hidden disabled></option><option id=\"broker-option-5\" hidden disabled></option><option id=\"broker-option-6\" hidden disabled></option><option id=\"broker-option-7\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"broker-assign\" class=\"button\" type=\"button\">Assign writer…</button><button id=\"broker-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"broker-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. Navigation or timeout does not cancel admitted work. Check Result after uncertainty; no automatic mutation retry.</p></div>\n" "<div id=\"broker-settings\" hidden><h2>Broker clients and writer</h2><p class=\"connection-detail\">One writer, multiple isolated observers. Viewing, refreshing and selecting do not change the lease or either terminal. Assignment revokes the previous writer, without recalling bytes already accepted by UART. Any intervening lease transition rejects stale confirmation, even release and reacquire by the same writer.</p><p class=\"connection-detail\">Pending and high-water are bounded output bytes; dropped counts cover this connection or the last shell counter clear. No UART data is consumed. Refresh retains explicit selection without renewing its lease token. Stale selections require choosing the blank option then the target again. No persistence or disconnect controls.</p><button id=\"broker-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"broker-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"broker-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Assign writer to<select id=\"broker-target\"><option value=\"\">Select a connected client</option><option id=\"broker-option-0\" hidden disabled></option><option id=\"broker-option-1\" hidden disabled></option><option id=\"broker-option-2\" hidden disabled></option><option id=\"broker-option-3\" hidden disabled></option><option id=\"broker-option-4\" hidden disabled></option><option id=\"broker-option-5\" hidden disabled></option><option id=\"broker-option-6\" hidden disabled></option><option id=\"broker-option-7\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"broker-assign\" class=\"button\" type=\"button\">Assign writer…</button><button id=\"broker-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"broker-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. Navigation or timeout does not cancel admitted work. Check Result after uncertainty; no automatic mutation retry.</p></div>\n"
"<div id=\"display-settings\" hidden><h2>Display</h2>\n" "<div id=\"display-settings\" hidden><h2>Display</h2>\n"
"<p class=\"connection-detail\">Working OLED inactivity settings, not saved NVS values. Zero disables a transition. Each timeout is 086400 seconds; when both are enabled, Off must be later than Dim.</p>\n" "<p class=\"connection-detail\">Working OLED inactivity settings, not saved NVS values. Zero disables a transition. Each timeout is 086400 seconds; when both are enabled, Off must be later than Dim.</p>\n"
"<p class=\"connection-detail\">Apply and Defaults change RAM only. Save persists the working snapshot, not browser drafts. Load discards drafts and uses stored settings, or defaults if storage is absent/incompatible; it does not change NVS. Reset saves defaults and applies them. Refresh discards drafts. Intervening configuration edits reject stale operations: Refresh and review before retrying.</p>\n" "<p class=\"connection-detail\">Apply and Defaults change RAM only. Save persists the working snapshot, not browser drafts. Load discards drafts and uses stored settings, or defaults if storage is absent/incompatible; it does not change NVS. Reset saves defaults and applies them. Refresh discards drafts. Intervening configuration edits reject stale operations: Refresh and review before retrying.</p>\n"
@@ -398,11 +398,14 @@ static const char s_app_js[] =
" element('serial-result').disabled = busy;\n" " element('serial-result').disabled = busy;\n"
"}\n" "}\n"
"let quick = null, quickLeaveTimer = 0, quickSuppressFocus = false, quickDismissed = null;\n" "let quick = null, quickLeaveTimer = 0, quickSuppressFocus = false, quickDismissed = null;\n"
"const quickDomains = ['serial','network','broker','writer'];\n"
"const quickTitle = domain => ({serial:'Serial quick settings',network:'Wi-Fi quick settings',broker:'Live broker clients',writer:'Active writer management'})[domain];\n"
"function resetQuick() {\n" "function resetQuick() {\n"
" window.clearTimeout(quickLeaveTimer); quickLeaveTimer = 0; quick = null;\n" " window.clearTimeout(quickLeaveTimer); quickLeaveTimer = 0; quick = null;\n"
" settingsHost.setAttribute('data-quick', 'false'); settingsHost.setAttribute('role', 'region');\n" " settingsHost.setAttribute('data-quick', 'false'); settingsHost.setAttribute('role', 'region');\n"
" element('quick-header').hidden = true; settingsHost.setAttribute('aria-label', 'Settings');\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" " stopBrokerLive();\n"
" for (const domain of quickDomains) element('quick-' + domain).setAttribute('aria-expanded', 'false');\n"
"}\n" "}\n"
"function closeQuick(focus = false) {\n" "function closeQuick(focus = false) {\n"
" if (!quick) return;\n" " if (!quick) return;\n"
@@ -416,13 +419,15 @@ static const char s_app_js[] =
" if (activate) quickDismissed = null;\n" " if (activate) quickDismissed = null;\n"
" if (quick?.domain === domain) { if (activate) element('quick-close').focus(); return; }\n" " if (quick?.domain === domain) { if (activate) element('quick-close').focus(); return; }\n"
" if (quick) closeQuick();\n" " if (quick) closeQuick();\n"
" // Full-page drafts retain their sole DOM owner; only explicit activation navigates there.\n" " // Broker contexts never navigate away from full-page drafts; Serial/Wi-Fi retain their explicit navigation.\n"
" if (selected === 'settings') { if (activate) { selectSettingsDomain(domain); element('settings-' + domain).focus(); } return; }\n" " const brokerContext = domain === 'broker' || domain === 'writer';\n"
" if (selected === 'settings') { if (activate) { if (!brokerContext) selectSettingsDomain(domain); element('settings-' + (brokerContext ? settingsDomain : domain)).focus(); } return; }\n"
" const previous = selected, trigger = element('quick-' + domain);\n" " const previous = selected, trigger = element('quick-' + domain);\n"
" selectTerminal('settings', false); selectSettingsDomain(domain, false); clearNetworkSecret();\n" " selectTerminal('settings', false); selectSettingsDomain(brokerContext ? 'broker' : domain, false); clearNetworkSecret();\n"
" quick = {domain, previous, trigger}; settingsHost.setAttribute('data-quick', 'true');\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" " settingsHost.setAttribute('role', 'dialog'); settingsHost.setAttribute('aria-label', quickTitle(domain));\n"
" element('quick-title').textContent = domain === 'serial' ? 'Serial quick settings' : 'Wi-Fi quick settings';\n" " element('quick-title').textContent = quickTitle(domain);\n"
" element('quick-help').textContent = brokerContext ? 'Live clients share one writer and isolated observation. Opening, refreshing and selecting never transfers the lease. Assign writer requires separate confirmation. Closing does not cancel admitted work; after uncertainty use Check Result, never automatic retry.' : '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.';\n"
" element('quick-header').hidden = false; trigger.setAttribute('aria-expanded', 'true');\n" " element('quick-header').hidden = false; trigger.setAttribute('aria-expanded', 'true');\n"
" terminalHost.hidden = previous !== 'serial'; adminHost.hidden = previous !== 'admin';\n" " terminalHost.hidden = previous !== 'serial'; adminHost.hidden = previous !== 'admin';\n"
" refreshSettings(); if (activate) element('quick-close').focus();\n" " refreshSettings(); if (activate) element('quick-close').focus();\n"
@@ -432,8 +437,9 @@ static const char s_app_js[] =
" window.clearTimeout(quickLeaveTimer); if (!quick) return;\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" " quickLeaveTimer = window.setTimeout(() => { quickLeaveTimer = 0; if (quick && !quickContains(document.activeElement) && !settingsHost.matches(':hover') && !quick.trigger.matches(':hover')) closeQuick(); }, 250);\n"
"}\n" "}\n"
"for (const domain of ['serial','network']) {\n" "for (const domain of quickDomains) {\n"
" const trigger = element('quick-' + domain);\n" " const trigger = element('quick-' + domain);\n"
" if (domain === 'broker' || domain === 'writer') trigger.setAttribute('title', 'Open client/writer management. When a full Settings page is open, use its Broker navigation or return to a terminal first; contextual triggers preserve that page and its drafts.');\n"
" trigger.addEventListener('pointerenter', event => { if (event.pointerType === 'mouse') openQuick(domain); });\n" " trigger.addEventListener('pointerenter', event => { if (event.pointerType === 'mouse') openQuick(domain); });\n"
" trigger.addEventListener('focus', () => openQuick(domain));\n" " trigger.addEventListener('focus', () => openQuick(domain));\n"
" trigger.addEventListener('click', () => openQuick(domain, true));\n" " trigger.addEventListener('click', () => openQuick(domain, true));\n"
@@ -694,7 +700,16 @@ static const char s_app_js[] =
"element('display-result').addEventListener('click', () => displayOperation(null));\n" "element('display-result').addEventListener('click', () => displayOperation(null));\n"
"for (const action of displayActions) element('display-' + action).addEventListener('click', () => displayOperation(action));\n" "for (const action of displayActions) element('display-' + action).addEventListener('click', () => displayOperation(action));\n"
"const brokerDetail = element('broker-detail');\n" "const brokerDetail = element('broker-detail');\n"
"let brokerClients = [], brokerWriter = 0;\n" "let brokerClients = [], brokerWriter = 0, brokerSelection = null, brokerLiveTimer = 0;\n"
"function stopBrokerLive() { window.clearTimeout(brokerLiveTimer); brokerLiveTimer = 0; }\n"
"function scheduleBrokerLive() {\n"
" stopBrokerLive();\n"
" if (!quick || settingsDomain !== 'broker' || brokerAbort || brokerAuto || brokerOperationPending) return;\n"
" brokerLiveTimer = window.setTimeout(() => { brokerLiveTimer = 0; refreshBroker(); }, 5000);\n"
"}\n"
"function brokerSelectionCurrent() {\n"
" return brokerSelection && !brokerSelection.stale && brokerSelection.generation === brokerGeneration && brokerClients.some(c => c.id === brokerSelection.target && c.type === brokerSelection.type && c.name_hex === brokerSelection.name_hex);\n"
"}\n"
"let brokerAbort = null, brokerGeneration = 0, brokerOperationAction = '';\n" "let brokerAbort = null, brokerGeneration = 0, brokerOperationAction = '';\n"
"const brokerActions = ['assign'];\n" "const brokerActions = ['assign'];\n"
"const brokerUint = v => Number.isInteger(v) && v >= 0 && v <= 4294967295;\n" "const brokerUint = v => Number.isInteger(v) && v >= 0 && v <= 4294967295;\n"
@@ -739,18 +754,25 @@ static const char s_app_js[] =
" auto.deadline = window.setTimeout(() => expireBrokerAuto(auto), 15000);\n" " auto.deadline = window.setTimeout(() => expireBrokerAuto(auto), 15000);\n"
" scheduleBrokerCheck();\n" " scheduleBrokerCheck();\n"
"}\n" "}\n"
"function brokerButton(id, disabled) {\n"
" const button = element('broker-' + id);\n"
" button.setAttribute('aria-disabled', String(disabled));\n"
" // Preserve keyboard focus during live updates; handlers independently gate all actions.\n"
" button.disabled = disabled && document.activeElement !== button;\n"
"}\n"
"function brokerButtons() {\n" "function brokerButtons() {\n"
" const busy = !!brokerAbort || !!brokerAuto;\n" " const busy = !!brokerAbort || !!brokerAuto;\n"
" const target = Number(element('broker-target').value);\n" " const target = Number(element('broker-target').value);\n"
" element('broker-assign').disabled = busy || brokerOperationPending || !brokerGeneration || brokerGeneration === 4294967295 || !brokerClients.some(c => c.id === target) || target === brokerWriter;\n" " brokerButton('assign', busy || brokerOperationPending || !brokerSelectionCurrent() || !brokerGeneration || brokerGeneration === 4294967295 || !brokerClients.some(c => c.id === target) || target === brokerWriter);\n"
" element('broker-target').disabled = busy || brokerOperationPending || !brokerGeneration;\n" " brokerButton('target', (!!brokerAbort && !brokerAbort.brokerRead) || !!brokerAuto || brokerOperationPending || !brokerGeneration);\n"
" element('broker-refresh').disabled = busy;\n" " brokerButton('refresh', busy);\n"
" element('broker-result').disabled = busy;\n" " brokerButton('result', busy);\n"
"}\n" "}\n"
"function clearBroker() {\n" "function clearBroker() {\n"
" if (!brokerAuto && brokerOperationPending) element('broker-operation-detail').textContent = brokerOutcomeWarning + 'Outcome pending or unknown. Check Result on return; navigation does not cancel work.';\n" " if (!brokerAuto && brokerOperationPending) element('broker-operation-detail').textContent = brokerOutcomeWarning + 'Outcome pending or unknown. Check Result on return; navigation does not cancel work.';\n"
" stopBrokerAuto(true);\n" " stopBrokerAuto(true);\n"
" if (brokerAbort) brokerAbort.abort(); brokerAbort = null; brokerGeneration = 0;\n" " if (brokerAbort) brokerAbort.abort(); brokerAbort = null; brokerGeneration = 0;\n"
" stopBrokerLive(); brokerSelection = null;\n"
" brokerClients = []; brokerWriter = 0; element('broker-target').value = '';\n" " brokerClients = []; brokerWriter = 0; element('broker-target').value = '';\n"
" element('broker-values').textContent = '';\n" " element('broker-values').textContent = '';\n"
" for (let i = 0; i < 8; ++i) { const o = element('broker-option-' + i); o.textContent = ''; o.hidden = o.disabled = true; }\n" " for (let i = 0; i < 8; ++i) { const o = element('broker-option-' + i); o.textContent = ''; o.hidden = o.disabled = true; }\n"
@@ -758,7 +780,10 @@ static const char s_app_js[] =
"}\n" "}\n"
"async function refreshBroker() {\n" "async function refreshBroker() {\n"
" if (settingsDomain !== 'broker' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || brokerAbort || brokerAuto) return;\n" " if (settingsDomain !== 'broker' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || brokerAbort || brokerAuto) return;\n"
" const controller = new AbortController(), generation = workGeneration; brokerAbort = controller; brokerButtons();\n" " stopBrokerLive();\n"
" const controller = new AbortController(), generation = workGeneration; controller.brokerRead = true; brokerAbort = controller; brokerButtons();\n"
" const deadline = window.setTimeout(() => { if (brokerAbort !== controller) return; controller.abort(); brokerAbort = null; brokerGeneration = 0; if (brokerSelection) brokerSelection.stale = true; brokerDetail.textContent = 'Live refresh timed out. Selection stale; Refresh and reselect. Automatic refresh stopped.'; brokerButtons(); }, 5000);\n"
" controller.signal.addEventListener('abort', () => window.clearTimeout(deadline), {once:true});\n"
" const current = () => brokerAbort === controller && selected === 'settings' && settingsDomain === 'broker';\n" " const current = () => brokerAbort === controller && selected === 'settings' && settingsDomain === 'broker';\n"
" brokerDetail.textContent = 'Reading broker clients... Previous snapshot is stale until refreshed.';\n" " brokerDetail.textContent = 'Reading broker clients... Previous snapshot is stale until refreshed.';\n"
" try {\n" " try {\n"
@@ -766,7 +791,8 @@ static const char s_app_js[] =
" const {status, payload: v} = await api('/api/settings/broker', generation, {signal: controller.signal, limit: 2048, current});\n" " const {status, payload: v} = await api('/api/settings/broker', generation, {signal: controller.signal, limit: 2048, current});\n"
" if (status !== 200 || !v || Object.keys(v).length !== 3 || !Number.isInteger(v.generation) || v.generation < 1 || v.generation > 4294967295 || !brokerValid(v)) throw new Error('Invalid snapshot');\n" " if (status !== 200 || !v || Object.keys(v).length !== 3 || !Number.isInteger(v.generation) || v.generation < 1 || v.generation > 4294967295 || !brokerValid(v)) throw new Error('Invalid snapshot');\n"
" brokerGeneration = v.generation; brokerClients = v.clients; brokerWriter = v.writer;\n" " brokerGeneration = v.generation; brokerClients = v.clients; brokerWriter = v.writer;\n"
" element('broker-target').value = '';\n" " if (brokerSelection && !brokerSelectionCurrent()) brokerSelection.stale = true;\n"
" if (brokerSelection && !brokerClients.some(c => c.id === brokerSelection.target)) brokerSelection.missing = true;\n"
" const list = element('broker-values'); list.textContent = '';\n" " const list = element('broker-values'); list.textContent = '';\n"
" for (let i = 0; i < 8; ++i) {\n" " for (let i = 0; i < 8; ++i) {\n"
" const c = brokerClients[i], o = element('broker-option-' + i); o.hidden = o.disabled = !c;\n" " const c = brokerClients[i], o = element('broker-option-' + i); o.hidden = o.disabled = !c;\n"
@@ -778,10 +804,11 @@ static const char s_app_js[] =
" list.appendChild(dt); list.appendChild(dd);\n" " list.appendChild(dt); list.appendChild(dd);\n"
" }\n" " }\n"
" }\n" " }\n"
" brokerDetail.textContent = (brokerOperationPending ? 'Outcome pending or unknown. ' : '') + 'Writer: ' + (brokerWriter || 'None') + '. ' + brokerClients.length + ' connected clients. Refresh clears selection; review and explicitly confirm assignment.' + (brokerGeneration === 4294967295 ? ' Confirmation generation exhausted; use the admin shell.' : '');\n" " element('broker-target').value = brokerSelection && !brokerSelection.missing && brokerClients.some(c => c.id === brokerSelection.target) ? String(brokerSelection.target) : '';\n"
" brokerDetail.textContent = (brokerOperationPending ? 'Outcome pending or unknown. ' : '') + 'Writer: ' + (brokerWriter || 'None') + '. ' + brokerClients.length + ' connected clients. ' + (brokerSelection?.stale ? 'Selection stale: lease or client changed. Choose the blank option then explicitly select again before confirmation. ' : 'Refresh preserves selection, never renews its confirmation token. ') + (quick ? 'Live refresh every 5 seconds after completion. ' : '') + (brokerGeneration === 4294967295 ? 'Confirmation generation exhausted; use the admin shell.' : '');\n"
" } catch (error) {\n" " } catch (error) {\n"
" if (live(generation) && current()) { brokerGeneration = 0; brokerDetail.textContent = (error.status ? error.message : 'Broker snapshot unavailable or invalid.') + ' Select Refresh to retry.'; }\n" " if (live(generation) && current()) { brokerGeneration = 0; if (brokerSelection) brokerSelection.stale = true; brokerDetail.textContent = (error.status ? error.message : 'Broker snapshot unavailable or invalid.') + ' Selection stale. Select Refresh and reselect to retry.'; }\n"
" } finally { if (current()) { brokerAbort = null; brokerButtons(); } }\n" " } finally { window.clearTimeout(deadline); if (current()) { brokerAbort = null; brokerButtons(); if (brokerGeneration) scheduleBrokerLive(); } }\n"
"}\n" "}\n"
"async function brokerOperation(action, automatic = false) {\n" "async function brokerOperation(action, automatic = false) {\n"
" if (settingsDomain !== 'broker') return;\n" " if (settingsDomain !== 'broker') return;\n"
@@ -792,19 +819,21 @@ static const char s_app_js[] =
" let body;\n" " let body;\n"
" if (action) {\n" " if (action) {\n"
" const target = Number(element('broker-target').value), client = brokerClients.find(c => c.id === target);\n" " const target = Number(element('broker-target').value), client = brokerClients.find(c => c.id === target);\n"
" if (action !== 'assign' || !client || !brokerGeneration || brokerGeneration === 4294967295 || target === brokerWriter) return;\n" " if (!brokerSelectionCurrent()) { detail.textContent = 'Selection stale or missing. Refresh, explicitly select and confirm again. No assignment submitted.'; return; }\n"
" const value = {action, generation: brokerGeneration, target};\n" " if (action !== 'assign' || !client || !brokerGeneration || brokerGeneration === 4294967295 || target === brokerWriter || target !== brokerSelection.target) return;\n"
" const value = {action, generation: brokerSelection.generation, target};\n"
" if (!window.confirm('Assign the writer lease to ' + brokerLabel(client) + '? Current writer: ' + (brokerWriter || 'None') + '. The previous writer becomes an observer. Already queued UART bytes are not recalled.')) return;\n" " if (!window.confirm('Assign the writer lease to ' + brokerLabel(client) + '? Current writer: ' + (brokerWriter || 'None') + '. The previous writer becomes an observer. Already queued UART bytes are not recalled.')) return;\n"
" body = JSON.stringify(value);\n" " body = JSON.stringify(value);\n"
" if (new TextEncoder().encode(body).length > 256) return;\n" " if (new TextEncoder().encode(body).length > 256) return;\n"
" }\n" " }\n"
" stopBrokerLive(); if (action && brokerSelection) brokerSelection.stale = true;\n"
" const controller = new AbortController(), generation = workGeneration; brokerAbort = controller;\n" " const controller = new AbortController(), generation = workGeneration; brokerAbort = controller;\n"
" const auto = automatic ? brokerAuto : null;\n" " const auto = automatic ? brokerAuto : null;\n"
" const current = () => {\n" " const current = () => {\n"
" if (auto && brokerAuto === auto && performance.now() >= auto.until) expireBrokerAuto(auto);\n" " if (auto && brokerAuto === auto && performance.now() >= auto.until) expireBrokerAuto(auto);\n"
" return brokerAbort === controller && selected === 'settings' && settingsDomain === 'broker';\n" " return brokerAbort === controller && selected === 'settings' && settingsDomain === 'broker';\n"
" };\n" " };\n"
" element('broker-refresh').disabled = true; brokerButtons();\n" " brokerButtons();\n"
" detail.textContent = brokerOutcomeWarning + (action ? 'Assigning... Submitting once; completion will be checked automatically.' : 'Reading latest result for this login...');\n" " detail.textContent = brokerOutcomeWarning + (action ? 'Assigning... Submitting once; completion will be checked automatically.' : 'Reading latest result for this login...');\n"
" brokerDetail.textContent = 'Snapshot stale: operation pending or outcome not yet checked.';\n" " brokerDetail.textContent = 'Snapshot stale: operation pending or outcome not yet checked.';\n"
" try {\n" " try {\n"
@@ -848,7 +877,12 @@ static const char s_app_js[] =
"element('broker-refresh').addEventListener('click', refreshBroker);\n" "element('broker-refresh').addEventListener('click', refreshBroker);\n"
"element('broker-result').addEventListener('click', () => brokerOperation(null));\n" "element('broker-result').addEventListener('click', () => brokerOperation(null));\n"
"for (const action of brokerActions) element('broker-' + action).addEventListener('click', () => brokerOperation(action));\n" "for (const action of brokerActions) element('broker-' + action).addEventListener('click', () => brokerOperation(action));\n"
"element('broker-target').addEventListener('change', brokerButtons);\n" "element('broker-target').addEventListener('change', () => {\n"
" const client = brokerClients.find(c => c.id === Number(element('broker-target').value));\n"
" brokerSelection = client && (!brokerAbort || brokerAbort.brokerRead) && !brokerOperationPending && brokerGeneration ? {target:client.id, type:client.type, name_hex:client.name_hex, generation:brokerGeneration, stale:false} : null;\n"
" if (brokerSelection) brokerDetail.textContent = 'Explicit selection: ' + brokerLabel(client) + '. Confirm assignment separately; refresh never renews this lease token.';\n"
" brokerButtons();\n"
"});\n"
"let settingsDomain = 'serial', accounts = [], accountsAbort = null, accountId = 0, accountPending = false, accountAwaitingAck = false, accountWarning = '';\n" "let settingsDomain = 'serial', accounts = [], accountsAbort = null, accountId = 0, accountPending = false, accountAwaitingAck = false, accountWarning = '';\n"
"let keysAbort = null, accountKeys = [], keysIdentity = '';\n" "let keysAbort = null, accountKeys = [], keysIdentity = '';\n"
"function keyIdentity() { const t = accounts[Number(element('account-target').value)]; return t ? JSON.stringify([t.username,t.user_id,t.auth_generation]) : ''; }\n" "function keyIdentity() { const t = accounts[Number(element('account-target').value)]; return t ? JSON.stringify([t.username,t.user_id,t.auth_generation]) : ''; }\n"
@@ -1549,7 +1583,7 @@ static const char s_app_js[] =
" if (accountRole !== 'admin') { closeAdmin(); selectTerminal('serial'); }\n" " if (accountRole !== 'admin') { closeAdmin(); selectTerminal('serial'); }\n"
" terminalHost.hidden = (quick?.previous || selected) !== 'serial'; adminHost.hidden = (quick?.previous || selected) !== 'admin';\n" " terminalHost.hidden = (quick?.previous || selected) !== 'serial'; adminHost.hidden = (quick?.previous || selected) !== 'admin';\n"
" settingsHost.hidden = selected !== 'settings' || loggingOut || suspended;\n" " settingsHost.hidden = selected !== 'settings' || loggingOut || suspended;\n"
" for (const domain of ['serial','network']) element('quick-' + domain).disabled = accountRole !== 'admin' || loggingOut || suspended;\n" " for (const domain of quickDomains) element('quick-' + domain).disabled = accountRole !== 'admin' || loggingOut || suspended;\n"
" scheduleFit();\n" " scheduleFit();\n"
" const deadline = Date.now() + payload.expires_in * 1000;\n" " const deadline = Date.now() + payload.expires_in * 1000;\n"
" sessionDeadline = sessionDeadline ? Math.min(sessionDeadline, deadline) : deadline;\n" " sessionDeadline = sessionDeadline ? Math.min(sessionDeadline, deadline) : deadline;\n"
+92 -3
View File
@@ -8,6 +8,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
const n = (b,id) => b.nodes['broker-'+id]; const n = (b,id) => b.nodes['broker-'+id];
const posts = b => b.calls.filter(c => c.url === op && c.method === 'POST'); const posts = b => b.calls.filter(c => c.url === op && c.method === 'POST');
const gets = b => b.calls.filter(c => c.url === op && c.method === 'GET'); const gets = b => b.calls.filter(c => c.url === op && c.method === 'GET');
function liveTimer(b) { const entry=[...b.timers].find(([,t])=>t.ms===5000&&!t.interval); assert.ok(entry, 'missing live/deadline timer'); b.timers.delete(entry[0]); entry[1].fn(); }
const reads = b => b.calls.filter(c => c.url === path); const reads = b => b.calls.filter(c => c.url === path);
async function open(value=fixture()) { async function open(value=fixture()) {
const b=await adminBrowser(); b.click('select-settings'); await tick(); const b=await adminBrowser(); b.click('select-settings'); await tick();
@@ -15,6 +16,94 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
} }
function select(b,id=9) { n(b,'target').value=String(id); n(b,'target').change(); } function select(b,id=9) { n(b,'target').value=String(id); n(b,'target').change(); }
async function submit(b) { select(b); b.window.confirm=()=>true; b.queues[op].push(reply('pending',42,202)); b.click('broker-assign'); await tick(); } async function submit(b) { select(b); b.window.confirm=()=>true; b.queues[op].push(reply('pending',42,202)); b.click('broker-assign'); await tick(); }
async function quick(domain='broker', mode='serial') {
const b=await adminBrowser(); b.click('select-'+mode);
b.nodes['serial-settings'].appendChild(b.nodes['quick-close']);
b.queues[path].push(json(fixture())); b.click('quick-'+domain); await tick(); return b;
}
await test('Contextual broker/writer native triggers support pointer keyboard touch, admin-only management and terminal input/focus isolation',async()=>{
for(const domain of ['broker','writer']) {
const u=browser();u.start();await tick();assert.ok(u.nodes['quick-'+domain].disabled);
u.nodes['quick-'+domain].pointerenter({pointerType:'mouse'});u.nodes['quick-'+domain].focus();u.click('quick-'+domain);await tick();assert.equal(reads(u).length,0);
for(const mode of ['serial','admin']) {
const b=await quick(domain,mode), trigger=b.nodes['quick-'+domain], host=b.nodes['serial-settings'];
assert.equal(host.role,'dialog');assert.equal(trigger['aria-expanded'],'true');assert.equal(b.document.activeElement,b.nodes['quick-close']);
assert.equal(posts(b).length,0);select(b);assert.equal(posts(b).length,0);
for(let i=0;i<2;++i){b.sockets[i].emit('message',{data:Uint8Array.of(255,0,i).buffer});assert.deepEqual(b.terminals[i].writes.at(-1),[255,0,i]);b.terminals[i].input('blocked');assert.equal(b.sockets[i].sent.length,0);}
const focus=b.terminals.map(t=>t.focusCalls);
b.emit('keydown',{key:'Escape',preventDefault(){},stopPropagation(){}});
assert.equal(b.document.activeElement,trigger);assert.deepEqual(b.terminals.map(t=>t.focusCalls),focus);assert.equal(host['data-quick'],'false');
trigger.pointerenter({pointerType:'touch'});await tick();assert.equal(host['data-quick'],'false');
b.queues[path].push(json(fixture()));b.click('quick-'+domain);await tick();assert.equal(host['data-quick'],'true');
b.emit('pointerdown',{target:b.nodes['connection-toggle']});b.nodes['connection-toggle'].focus();assert.equal(b.document.activeElement,b.nodes['connection-toggle']);assert.deepEqual(b.terminals.map(t=>t.focusCalls),focus);
trigger.pointerleave();b.queues[path].push(json(fixture()));trigger.focus();await tick();assert.equal(trigger['aria-expanded'],'true');
b.nodes['quick-close'].focus();trigger.pointerleave();b.fire(250);assert.equal(host['data-quick'],'true');
}
}
});
await test('Live refresh is single-flight, preserves explicit selection across reorder/counters and never rebases lease/identity tokens',async()=>{
const b=await quick();select(b);const d=deferred();b.queues[path].push(d.promise);liveTimer(b);await tick();
assert.equal(reads(b).length,2);assert.ok(n(b,'assign').disabled);assert.equal(n(b,'target').disabled,false);
b.click('broker-refresh');b.click('quick-broker');await tick();assert.equal(reads(b).length,2);
d.resolve(json(fixture({clients:[{...row(9,3),pending:0},row(8)]})));await tick();
assert.equal(n(b,'target').value,'9');assert.equal(n(b,'assign').disabled,false);
b.queues[path].push(json(fixture({generation:8})));liveTimer(b);await tick();assert.equal(n(b,'target').value,'9');assert.ok(n(b,'assign').disabled);assert.match(n(b,'detail').textContent,/Selection stale/);
b.queues[path].push(json(fixture()));liveTimer(b);await tick();assert.ok(n(b,'assign').disabled);b.click('broker-assign');await tick();assert.equal(posts(b).length,0);assert.match(n(b,'operation-detail').textContent,/Selection stale/);
select(b);assert.equal(n(b,'assign').disabled,false);
b.queues[path].push(json(fixture({clients:[row(8),{...row(9,3),name_hex:'61'}]})));liveTimer(b);await tick();assert.ok(n(b,'assign').disabled);
b.queues[path].push(json(fixture()));liveTimer(b);await tick();assert.ok(n(b,'assign').disabled);
select(b);b.queues[path].push(json(fixture({clients:[row(8)]})));liveTimer(b);await tick();assert.equal(n(b,'target').value,'');
b.queues[path].push(json(fixture()));liveTimer(b);await tick();assert.equal(n(b,'target').value,'');assert.ok(n(b,'assign').disabled);assert.equal(posts(b).length,0);
});
await test('Live refresh preserves focused action/select controls with aria-disabled gating and visible stale rejection',async()=>{
for(const id of ['assign','refresh','result','target']) {
const b=await quick();select(b);b.nodes['serial-settings'].appendChild(n(b,id));n(b,id).focus();
const focus=b.terminals.map(t=>t.focusCalls), d=deferred();b.queues[path].push(d.promise);liveTimer(b);await tick();
assert.equal(b.document.activeElement,n(b,id));assert.equal(n(b,id).disabled,false);
if(id!=='target'){assert.equal(n(b,id)['aria-disabled'],'true');b.click('broker-'+id);await tick();assert.equal(reads(b).length,2);assert.equal(posts(b).length,0);assert.equal(gets(b).length,0);}
d.resolve(json(fixture({generation:8})));await tick();assert.equal(b.document.activeElement,n(b,id));assert.equal(n(b,id).disabled,false);assert.deepEqual(b.terminals.map(t=>t.focusCalls),focus);
if(id==='assign'){assert.equal(n(b,id)['aria-disabled'],'true');b.click('broker-assign');await tick();assert.match(n(b,'operation-detail').textContent,/Selection stale/);assert.equal(posts(b).length,0);}
}
});
await test('Live timeout/error stops automatic refresh and stale late responses cannot restore selection or close a newer view',async()=>{
for(const response of [failure(503),json({invalid:true})]) {
const b=await quick();select(b);b.queues[path].push(response);liveTimer(b);await tick();assert.ok(n(b,'assign').disabled);assert.ok(![...b.timers.values()].some(t=>(t.ms===5000&&!t.interval)));
}
for(const stage of ['session','snapshot']) {
const b=await quick();select(b);const d=deferred();b.queues[stage==='session'?'/api/session':path].push(d.promise);liveTimer(b);await tick();liveTimer(b);await tick();
assert.match(n(b,'detail').textContent,/timed out/);assert.ok(n(b,'assign').disabled);assert.ok(![...b.timers.values()].some(t=>(t.ms===5000&&!t.interval)));
b.queues[path].push(json(fixture()));b.click('broker-refresh');await tick();assert.ok(n(b,'assign').disabled);
d.resolve(stage==='session'?session({role:'admin'}):json(fixture({generation:99})));await tick();assert.ok(n(b,'assign').disabled);select(b);
b.queues[op].push(reply('pending',42,202));b.click('broker-assign');await tick();assert.equal(JSON.parse(posts(b)[0].body).generation,7);
}
});
await test('Context triggers preserve every full-page draft and coordinate sole DOM ownership with Serial/WiFi quick views',async()=>{
const b=await adminBrowser();b.click('select-settings');await tick();b.nodes['edit-baud'].value='12345';
for(const domain of ['broker','writer']) {b.nodes['quick-'+domain].pointerenter({pointerType:'mouse'});b.nodes['quick-'+domain].focus();b.click('quick-'+domain);await tick();assert.equal(b.nodes['edit-baud'].value,'12345');assert.equal(reads(b).length,0);}
b.click('settings-accounts');await tick();b.nodes['account-password'].value='unsubmitted';b.click('quick-writer');await tick();assert.equal(b.nodes['account-password'].value,'unsubmitted');
b.click('settings-network');await tick();b.nodes['network-password'].value='unsubmitted';b.click('quick-broker');await tick();assert.equal(b.nodes['network-password'].value,'unsubmitted');
b.click('select-serial');b.click('quick-serial');await tick();b.nodes['quick-serial'].focusout();b.queues[path].push(json(fixture()));b.nodes['quick-writer'].focus();await tick();assert.equal(b.document.activeElement,b.nodes['quick-writer']);assert.equal(b.nodes['quick-serial']['aria-expanded'],'false');
select(b);const count=reads(b).length;b.nodes['quick-full'].click({preventDefault(){}});await tick();assert.equal(n(b,'target').value,'9');assert.equal(reads(b).length,count);assert.ok(![...b.timers.values()].some(t=>(t.ms===5000&&!t.interval)));
});
await test('Context stale confirmation retains captured lease through delayed validation; conflict visible and no replay on dismiss/reopen',async()=>{
const b=await quick('writer');select(b);const d=deferred();b.queues['/api/session'].push(d.promise);b.queues[op].push(reply('pending',42,202));b.click('broker-assign');await tick();assert.equal(posts(b).length,0);
b.click('broker-refresh');await tick();d.resolve(session({role:'admin'}));await tick();assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'assign',generation:7,target:9});
b.queues[op].push(reply('conflict'));b.queues[path].push(json(fixture({generation:10})));b.fire(1000);await tick();assert.match(n(b,'operation-detail').textContent,/No lease change/);assert.ok(n(b,'assign').disabled);
b.click('quick-close');b.queues[path].push(json(fixture()));b.click('quick-writer');await tick();assert.equal(posts(b).length,1);assert.equal(n(b,'target').value,'');
select(b);b.queues[op].push(()=>{throw Error('lost');});b.click('broker-assign');await tick();b.click('quick-close');b.queues[path].push(json(fixture()));b.click('quick-writer');await tick();assert.equal(posts(b).length,2);assert.match(n(b,'operation-detail').textContent,/unknown/);assert.ok(n(b,'assign').disabled);assert.ok(![...b.timers.values()].some(t=>(t.ms===5000&&!t.interval)||t.ms===1000));
});
await test('Context read/operation/session identity fencing covers expiry pagehide late401 and newer quick ownership',async()=>{
for(const stage of ['read','post','result']) {
const b=await quick();const d=deferred();
if(stage==='read'){b.queues[path].push(d.promise);liveTimer(b);}else if(stage==='post'){select(b);b.queues[op].push(d.promise);b.click('broker-assign');}else{await submit(b);b.queues[op].push(d.promise);b.fire(1000);}
await tick();b.click('quick-serial');await tick();d.resolve(failure(401));await tick();assert.deepEqual(b.redirects,[]);assert.equal(b.nodes['quick-serial']['aria-expanded'],'true');assert.ok(b.sockets.every(s=>!s.closed));
}
for(const action of ['expiry','pagehide','identity','401']) {
const b=await quick();select(b);
if(action==='expiry')b.window.sakSessionExpired();else if(action==='pagehide')b.emit('pagehide');else{if(action==='identity')b.queues['/api/session'].push(session({role:'admin',username:'other'}));else b.queues[path].push(failure(401));liveTimer(b);}
await tick();assert.ok(b.sockets.every(s=>s.closed));assert.equal(n(b,'target').value,'');assert.equal(posts(b).length,0);assert.ok(![...b.timers.values()].some(t=>(t.ms===5000&&!t.interval)));
}
});
await test('Broker admin-only full page, label/value style, bounded safe rows; view/selection do not mutate or close either terminal', async()=>{ await test('Broker admin-only full page, label/value style, bounded safe rows; view/selection do not mutate or close either terminal', async()=>{
for(const id of ['settings-broker','broker-values','broker-target','broker-assign','broker-refresh','broker-result']) assert.ok(html.includes('id="'+id+'"')); for(const id of ['settings-broker','broker-values','broker-target','broker-assign','broker-refresh','broker-result']) assert.ok(html.includes('id="'+id+'"'));
const u=browser(); u.start(); await tick(); u.click('settings-broker'); await tick(); assert.equal(reads(u).length,0); const u=browser(); u.start(); await tick(); u.click('settings-broker'); await tick(); assert.equal(reads(u).length,0);
@@ -33,13 +122,13 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
const empty=await open(fixture({writer:0,clients:[]})); assert.match(n(empty,'detail').textContent,/0 connected/); const empty=await open(fixture({writer:0,clients:[]})); assert.match(n(empty,'detail').textContent,/0 connected/);
const c=await open(); c.queues[path].push(new Response('x'.repeat(2049))); c.click('broker-refresh'); await tick(); select(c); assert.ok(n(c,'assign').disabled); const c=await open(); c.queues[path].push(new Response('x'.repeat(2049))); c.click('broker-refresh'); await tick(); select(c); assert.ok(n(c,'assign').disabled);
}); });
await test('Broker explicit confirmation captures target and lease generation, cancel/current-writer/selection never POST; refresh clears selection',async()=>{ await test('Broker explicit confirmation captures target and lease generation, cancel/current-writer/selection never POST; refresh retains stale selection',async()=>{
const b=await open(); select(b,8); b.click('broker-assign'); await tick(); assert.equal(posts(b).length,0); const b=await open(); select(b,8); b.click('broker-assign'); await tick(); assert.equal(posts(b).length,0);
select(b); let prompt=''; b.window.confirm=text=>{prompt=text;return false;}; b.click('broker-assign'); await tick(); assert.equal(posts(b).length,0); assert.match(prompt,/9 \/ SSH/); assert.match(prompt,/Current writer: 8/); select(b); let prompt=''; b.window.confirm=text=>{prompt=text;return false;}; b.click('broker-assign'); await tick(); assert.equal(posts(b).length,0); assert.match(prompt,/9 \/ SSH/); assert.match(prompt,/Current writer: 8/);
await submit(b); assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'assign',generation:7,target:9}); assert.equal(posts(b)[0].headers['X-CSRF-Token'],token); await submit(b); assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'assign',generation:7,target:9}); assert.equal(posts(b)[0].headers['X-CSRF-Token'],token);
b.click('broker-assign'); await tick(); assert.equal(posts(b).length,1); b.click('broker-assign'); await tick(); assert.equal(posts(b).length,1);
b.queues[op].push(reply('ok')); b.queues[path].push(json(fixture({generation:8,writer:9}))); b.fire(1000); await tick(); b.queues[op].push(reply('ok')); b.queues[path].push(json(fixture({generation:8,writer:9}))); b.fire(1000); await tick();
assert.equal(n(b,'target').value,''); assert.ok(n(b,'assign').disabled); assert.match(n(b,'operation-detail').textContent,/completed/); assert.equal(posts(b).length,1); assert.equal(n(b,'target').value,'9'); assert.ok(n(b,'assign').disabled); assert.match(n(b,'detail').textContent,/Selection stale/); assert.match(n(b,'operation-detail').textContent,/completed/); assert.equal(posts(b).length,1);
}); });
await test('Broker stale/failed/cancelled completion refreshes without retry; stale target removal clears explicit selection',async()=>{ await test('Broker stale/failed/cancelled completion refreshes without retry; stale target removal clears explicit selection',async()=>{
for(const state of ['conflict','failed','cancelled']) { for(const state of ['conflict','failed','cancelled']) {
@@ -52,7 +141,7 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
const b=await open(); await submit(b); const b=await open(); await submit(b);
for(let i=0;i<10;++i){b.queues[op].push(reply());b.fire(1000);await tick();} for(let i=0;i<10;++i){b.queues[op].push(reply());b.fire(1000);await tick();}
assert.equal(gets(b).length,10);assert.equal(posts(b).length,1);assert.match(n(b,'operation-detail').textContent,/Automatic checking stopped/); assert.equal(gets(b).length,10);assert.equal(posts(b).length,1);assert.match(n(b,'operation-detail').textContent,/Automatic checking stopped/);
const c=await open();await submit(c);const d=deferred();c.queues['/api/session'].push(d.promise);c.fire(1000);await tick();c.elapse(15000);c.fire(15000);await tick();d.resolve(session({role:'admin',username:'alice'}));await tick();assert.equal(gets(c).length,0); const c=await open();await submit(c);const d=deferred();c.queues['/api/session'].push(d.promise);c.fire(1000);await tick();c.elapse(15000);c.fire(15000);await tick();d.resolve(session({role:'admin'}));await tick();assert.equal(gets(c).length,0);
}); });
await test('Broker lost acknowledgement/replaced result/invalid state preserve uncertainty and never replay',async()=>{ await test('Broker lost acknowledgement/replaced result/invalid state preserve uncertainty and never replay',async()=>{
const b=await open();select(b);b.window.confirm=()=>true;b.queues[op].push(()=>{throw Error('lost');});b.click('broker-assign');await tick(); const b=await open();select(b);b.window.confirm=()=>true;b.queues[op].push(()=>{throw Error('lost');});b.click('broker-assign');await tick();
+1 -1
View File
@@ -45,7 +45,7 @@ def check_layout(html):
if cls in classes(node): if cls in classes(node):
return node return node
raise AssertionError(cls) raise AssertionError(cls)
for domain in ('serial', 'network'): for domain in ('serial', 'network', 'broker', 'writer'):
trigger = ids['quick-' + domain] trigger = ids['quick-' + domain]
assert trigger['tag'] == 'button' and trigger['attrs']['type'] == 'button' assert trigger['tag'] == 'button' and trigger['attrs']['type'] == 'button'
assert trigger['attrs']['aria-controls'] == 'serial-settings' assert trigger['attrs']['aria-controls'] == 'serial-settings'