ouroboros/tests/test_widgets_ui_static.py
Ouroboros cbd1b78668 feat(settings): the Agents tab — grouped accounts, review lanes, delegation
The owner's report (2026-08-08) was that the accounts UI is "кривоват и не
прозрачен": the add-account button sat in an illogical place, accounts of one
agent were not equivalent, and the limits text was neither compact nor
understandable. The agent surfaces were also spread across three tabs, so no
single screen answered "who does the work here".

NEW TAB "Agents", between Models and Behavior. Named for what these
subscriptions actually do — the owner's words: "эти агенты не только для кода
используются, например для создания презентаций и любые tasks" (D-10). Top to
bottom: one service banner, Accounts, Review lanes, Delegation.

Moved in, each exactly once (no control is rendered twice — a move that leaves
the old markup behind is how two drafts of one settings key appear):
  Providers → Agents   Harness Accounts
  Models    → Agents   Reviewer Slots (now "Review lanes")
  Models    → Agents   Subagents (now "Delegation"), with mutative Off/Auto/On
  Advanced  → Agents   Active Subagents / Root, Subagent Depth
  Advanced  → Agents   the two subagent path roots, behind a collapsed Advanced
Max Workers stays in Advanced (process capacity, not an agent setting); API
keys stay in Providers; API model ids and effort lanes stay in Models.

ACCOUNTS are now one card per family. The family header carries the name, an
aggregate status that counts the accounts rotation can really use, and that
card's OWN add button — the affordance used to hang off the native row, which
is what made adding a Codex account a hunt. Rows inside are equivalent: the
default CLI login uses the same two-line layout as any named account and is
captioned "Managed by the <family> CLI" rather than laid out differently. Line
1 is the account and its status; line 2 is muted metadata in human words —
"38% used · resets in 2h", "Limit reached · resets in 2h", "Usage
unavailable", "Limits not checked" — never a raw ISO instant.

REMOVAL goes through the engine's own contract (DELETE
/v2/credential-profiles/:harness/:profileId behind a fourth thin proxy). A
native CLI login has no removal button: this process cannot honestly sign a
vendor CLI out, so it says who manages it instead of simulating one. A review
row pinned to an account discovery no longer lists keeps its pin and gets one
actionable sentence — it will refuse rather than reroute.

ONE SERVICE BANNER replaces the scattering of "(not in discovery)". It stays
per-facet: a refused quota read leaves the catalogue and accounts
authoritative and says so, rather than declaring the service down.

The all-delegated reviewer disclosure stopped advising against the ratified
default. It used to end "keep at least one API reviewer row to avoid the
fallback", which asks the owner to undo D-3 (with a subscription connected,
everything that can run on one does, and a triad is never half API). It is now
neutral routing information: commit and scope review run on subscriptions and
wait for capacity rather than falling back to API spend, while plan review,
task acceptance and skill review stay API-only on the shipped defaults.

Three defects were caught by rendering the tab and looking at it, and each is
pinned by a test: a spent window was DIMMED to 0.62 opacity (hiding the very
sentence that reports the limit, and making live buttons read as disabled), a
family with one signed-in and one cold account announced "2 accounts ·
rotating", and a row whose verification had failed offered "Connect".

Verified visually at 1280px and 420px across zero / one / several accounts per
family, service down, and a refused quota read.

No version carrier is touched.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-08-09 03:00:50 +03:00

318 lines
14 KiB
Python

"""Static contract checks for the Widgets page renderer."""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
def _widgets_js() -> str:
return (REPO_ROOT / "web" / "modules" / "widgets.js").read_text(
encoding="utf-8"
)
def _read(rel: str) -> str:
return (REPO_ROOT / rel).read_text(encoding="utf-8")
def test_widgets_support_declarative_schema_components():
"""Spot-check that widgets.js exposes the declarative schema entry point
and a representative set of components. Trimmed in v5.15.x — the full
type-marker enumeration (15+ entries) was brittle to schema evolution
and added little signal over a smoke check. Security/lifecycle pins
moved to the dedicated tests below (escape/sanitize, media source guard,
download host helper, etc.)."""
source = _widgets_js()
assert "render.kind === 'declarative'" in source
# Sentinel components — proof the declarative router is wired
assert "type === 'form'" in source
assert "type === 'action'" in source
assert "type === 'table'" in source
assert "type === 'markdown'" in source
# Lifecycle / cleanup discipline
assert "disposeMountedWidgets();" in source
assert "let widgetsMounted = false;" in source
assert "let renderGeneration = 0;" in source
page_shown_branch = source.split("window.addEventListener('ouro:page-shown'")[1]
assert "disposeMountedWidgets();" in page_shown_branch
def test_widgets_escape_and_sanitize_untrusted_content():
"""Widgets must reach the sanitised markdown helper through the v5.8.3-rc.5
SSOT (``web/modules/utils.js::renderMarkdownSafe``); the DOMPurify
allowlist itself moved to that module and is pinned by
``tests/test_web_utils_ssot.py::test_render_markdown_safe_strips_dangerous_tags_and_attrs``.
Widgets-side this test now only verifies the import and the
escapeHtml-around-untrusted-content discipline that remains local
(table cells, JSON dumps).
"""
source = _widgets_js()
assert "renderMarkdownSafe" in source
# Widgets must NOT redeclare the SSOT helper locally.
assert "function renderMarkdownSafe" not in source, (
"widgets.js must use renderMarkdownSafe from utils.js (SSOT), not a local copy"
)
assert "escapeHtml(JSON.stringify(value, null, 2))" in source
assert "renderTableCell(row, c)" in source
assert "function safeTableHref" in source
def test_widgets_media_sources_are_constrained_to_extension_routes_or_data_urls():
source = _widgets_js()
assert "function safeMediaSrc" in source
assert "effectiveTarget = ''" in source
assert "state[effectiveTarget || spec.target || 'result']" in source
assert "safeMediaSrc(tab, component, state, target)" in source
assert "const route = spec.route || spec.api_route || '';" in source
assert "extensionRoutePath(tab.skill, route, params)" in source
assert "data:(image\\/" in source
assert "parsed.pathname.startsWith(expectedPrefix)" in source
assert "parsed.origin === window.location.origin" in source
assert "javascript:" not in source
assert "`${treePath}.gallery.${idx}`, passiveTarget" in source
def test_widgets_downloads_use_host_handler_not_navigation():
source = _widgets_js()
helper = _read("web/modules/ui_helpers.js")
assert "data-widget-download-url" in source
assert "event.preventDefault();" in source
assert "downloadViaHostBridge(" in source
assert "download_file_to_downloads" in helper
assert "URL.createObjectURL" in helper
assert "window.location.href" not in source
assert "window.location.assign" not in source
assert '<a class="btn btn-default" href' not in source
def test_widgets_treat_head_as_no_body_request():
source = _widgets_js()
assert "const noBody = method === 'GET' || method === 'HEAD';" in source
assert "const init = noBody" in source
def test_widgets_keep_iframe_sandbox_locked_down():
"""The legacy ``kind: "iframe"`` widget surface mounts an extension
route inside a <iframe> with the *empty* sandbox attribute (no
permissions at all). v5.7.0 added ``kind: "module"``, which mounts
extension-supplied JS inside a separate <iframe srcdoc> with
``sandbox="allow-scripts"`` BUT no ``allow-same-origin`` token —
so the iframe is still an opaque origin (no SPA cookie / storage
access) and is further constrained by a strict CSP. We check both
invariants here:
1. The legacy iframe path still uses the empty sandbox.
2. The module iframe path adds ``allow-scripts`` but never adds
``allow-same-origin`` (the only token that would re-expose
parent storage).
"""
source = _widgets_js()
assert 'sandbox=""' in source
# ``allow-scripts`` is now legitimately present, but only inside the
# ``kind === 'module'`` branch. The dangerous combined sandbox token
# must never appear in an actual iframe attribute.
assert 'sandbox="allow-scripts"' in source
assert 'sandbox="allow-scripts allow-same-origin"' not in source
assert 'sandbox="allow-scripts allow-forms allow-same-origin"' not in source
assert "render.kind === 'module'" in source
# Verify the module iframe carries a CSP that does NOT grant network
# access directly. The parent injects a postMessage fetch bridge instead,
# restricted to /api/extensions/<skill>/... from the parent side.
assert "default-src 'none'" in source
assert "script-src 'unsafe-inline'" in source
assert "OuroborosWidget = { fetch: window.fetch }" in source
assert "module widget fetch outside extension route prefix" in source
def test_widgets_use_design_radius_tokens():
style = (REPO_ROOT / "web" / "style.css").read_text(encoding="utf-8")
block_start = style.index(".widget-field input,")
block_end = style.index("}", block_start)
block = style[block_start:block_end]
assert "border-radius: var(--radius-sm);" in block
assert "border-radius: 9px;" not in block
def test_widgets_refresh_button_shows_loading_state():
source = _widgets_js()
css = (REPO_ROOT / "web" / "style.css").read_text(encoding="utf-8")
assert "refreshBtn.classList.add('is-loading')" in source
assert "refreshBtn.classList.remove('is-loading')" in source
assert "refreshBtn.disabled = true" in source
assert "#widgets-refresh.is-loading::after" in css
def test_widgets_cards_do_not_stretch_to_row_height():
source = _widgets_js()
css = (REPO_ROOT / "web" / "style.css").read_text(encoding="utf-8")
masonry = (REPO_ROOT / "web" / "modules" / "masonry.js").read_text(encoding="utf-8")
assert "const span = Number(tab.span || tab.grid_span || 1);" in source
assert "widgets-card-span-2" in source
assert "applyMasonry(list)" in source
assert "function layout(container, config)" in masonry
assert "item.classList.contains(spanClass) ? 2 : 1" in masonry
assert "Math.min(desiredColumns, availableColumns)" in masonry
assert "itemResizeObserver" in masonry
assert "observeItems()" in masonry
widgets_block = css.split(".widgets-list {", 1)[1].split("}", 1)[0]
assert "display: grid" not in widgets_block
assert "position: relative;" in widgets_block
assert ".widgets-card-span-2" in css
def test_widget_form_label_is_accessible_heading_fallback():
source = _widgets_js()
assert "const heading = component.title || component.label || '';" in source
assert 'aria-label="${escapeHtml(heading)}"' in source
assert "heading ? `<h4>${escapeHtml(heading)}</h4>` : ''" in source
def test_widget_json_wraps_inside_its_host_card():
style = _read("web/style.css")
json_block = style.split(".widget-json pre {", 1)[1].split("}", 1)[0]
assert "max-width: 100%;" in json_block
assert "max-height: min(360px, 50vh);" in json_block
assert "overflow: auto;" in json_block
assert "white-space: pre-wrap;" in json_block
assert "overflow-wrap: anywhere;" in json_block
def test_widgets_card_order_is_owner_ui_preference():
source = _widgets_js()
css = (REPO_ROOT / "web" / "style.css").read_text(encoding="utf-8")
api_client = (REPO_ROOT / "web" / "modules" / "api_client.js").read_text(encoding="utf-8")
assert 'data-widget-reorder-handle' in source
assert "function sortTabsByWidgetOrder" in source
assert "originalIndex" in source
assert "return a.originalIndex - b.originalIndex;" in source
assert "Move widget: drag or use arrow keys" in source
assert "handle.addEventListener('keydown'" in source
assert "event.key === 'ArrowUp'" in source
assert "apiClient.uiPreferences()" in source
assert "apiClient.saveUiPreferences({ widget_order: normalized })" in source
assert "currentWidgetOrderFromDom(list)" in source
assert ".widgets-card-drag" in css
assert ".widgets-card.drag-over" in css
assert "uiPreferences: () => fetchJson('/api/ui/preferences'" in api_client
assert "saveUiPreferences: (payload) => jsonPost('/api/ui/preferences', payload)" in api_client
def test_widgets_inline_card_host_path_removed():
source = _widgets_js()
assert "render.kind === 'inline_card'" not in source
assert "skill-widget-weather" not in source
assert "const saved = widgetSessionState.get(persistenceKey) || {};" in source
def test_widgets_v5_7_0_new_components_render():
"""v5.7.0 host-owned declarative components: ``map`` (Leaflet-ready
fallback list), ``calendar`` (host SVG-style row list), ``kanban``
(HTML5 drag with on_move POST). All three must be present in the
declarative renderer so authors can reference them in widgets, and
none of them may bring skill-supplied JS into the SPA origin."""
source = _widgets_js()
assert "type === 'map'" in source
assert "type === 'calendar'" in source
assert "type === 'kanban'" in source
# Module / arbitrary <script> from the skill must NEVER be inserted
# into the host origin. ``data-widget-map-config`` carries the spec
# as JSON in a data attribute (host renders); no runtime eval of
# extension JS is acceptable in any of the new component renderers.
assert "data-widget-map-config" in source
assert "widget-kanban-card" in source
def test_widgets_render_subscription_children():
source = _widgets_js()
assert "type === 'subscription'" in source
assert "component.render" in source
assert "widget-subscription-render" in source
assert "inheritedTarget = ''" in source
assert "component.target || inheritedTarget || 'result'" in source
assert "renderComponent(tab, child, view, `${treePath}.render.${idx}`, target)" in source
assert "const passiveTarget = inheritedTarget ? target : '';" in source
assert "value_key" in source
assert "items_key" in source
assert "route_prefix" in source
assert "type === 'key_value'" in source
def test_widgets_schema_v1_composition_uses_stable_tree_keys():
source = _widgets_js()
assert "function componentIdentity" in source
assert "function indexComponentTree" in source
assert "type === 'group'" in source
assert "type === 'metric'" in source
assert "type === 'callout'" in source
assert "visibleKeys.forEach((key)" in source
assert "components[Number(" not in source
assert "data-widget-kanban-key" in source
def test_widgets_forms_charts_and_kanban_keep_host_owned_contracts():
source = _widgets_js()
helper = _read("web/modules/ui_helpers.js")
assert "renderSafeField(" in source
assert "collectSafeFieldValues(" in source
assert "includePasswords: false" in source
assert "pendingActions.has(key)" in source
assert "spanGaps: false" in source
assert "finiteChartValue" in source
assert "aria-label=" in source
assert "renderChartDataTable" in source
assert "data-widget-kanban-move" in source
assert "widget-kanban-empty" in source
assert "mount.querySelectorAll('[data-widget-kanban-key]')" in source
assert "{ card_id: cardId, column_id: columnId }" in source
assert "SAFE_FIELD_TYPES" in helper
assert "autocomplete=\"new-password\"" in helper
def test_widgets_responsive_design_system_styles_are_host_owned():
style = _read("web/style.css")
assert ".widget-group-grid" in style
assert ".widget-metric" in style
assert ".widget-callout" in style
assert ".widget-form-fields.widget-grid-cols-4" in style
assert "content: attr(data-label);" in style
assert ".widget-kanban-move" in style
assert ".widget-kanban-col.is-empty" in style
# The widget narrow block is found by its CONTENT, not by being the last
# `@media (max-width: 640px)` in the file. Position was never the fact under
# test, and any surface that later adds its own 640px block (the Agents tab's
# account rows did) would silently steal this assertion and fail it.
blocks = style.split("@media (max-width: 640px) {")[1:]
narrow = [b for b in blocks if ".widget-kanban-col.is-empty" in b]
assert narrow, "no @media (max-width: 640px) block carries the widget kanban rules"
assert any("min-height: 0;" in b for b in narrow)
assert any("padding-block: 8px;" in b for b in narrow)
assert ".widget-group-components > * { margin-top: 0; }" in style
assert "repeat(auto-fit, minmax(min(220px, 100%), 1fr))" in style
assert ".widget-group-grid > .widget-group-components > :is(" in style
def test_widget_public_tones_share_the_host_normalizer_and_canonical_css():
source = _widgets_js()
helper = _read("web/modules/ui_helpers.js")
style = _read("web/style.css")
assert "function widgetTone" not in source
assert "normalizeTone(component.tone)" in source
assert "normalizeTone(component.tone, 'info')" in source
assert "success: 'ok'" in helper
assert "warning: 'warn'" in helper
assert "neutral: 'muted'" in helper
assert '.widget-metric[data-tone="ok"], .widget-callout[data-tone="ok"]' in style
assert '.widget-metric[data-tone="warn"], .widget-callout[data-tone="warn"]' in style
def test_widget_metrics_share_the_standard_empty_value_and_numeric_formatter():
source = _widgets_js()
assert "const numericValue = text ? Number(text) : Number.NaN;" in source
assert "!Number.isNaN(numericValue) && !Number.isFinite(numericValue)" in source
assert "const structured = raw !== null && typeof raw === 'object';" in source
assert "nonFiniteText" in source
assert "typeof raw === 'number' || numericText ? formatNumber" in source