ouroboros/tests/test_widgets_ui_static.py
Anton 9b19b7b85a Merge current ouroboros into the recovered tools-759 contribution
Preserve the original resolved merge against the published UI-system baseline. The interrupted source index was recovered exactly; subsequent review corrections remain a separate content commit.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-11 02:37:25 +03:00

809 lines
47 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 _framed_widget_sources() -> str:
"""widgets.js (page host, dispatcher, declarative renderer) plus the framed
mounts split out of it (widget_module.js), the in-frame bootstrap
(widget_frame.js), the framed-card chrome (widget_card.js), the card
reorder handles (widget_reorder.js) and the declarative chart helpers
(widget_chart.js). Negative pins run against this union so the moved code
never leaves their coverage."""
return (
_widgets_js()
+ _read("web/modules/widget_module.js")
+ _read("web/modules/widget_frame.js")
+ _read("web/modules/widget_card.js")
+ _read("web/modules/widget_reorder.js")
+ _read("web/modules/widget_chart.js")
)
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: a list rebuild disposes every mounted
# card first; leaving stops everything except the frames the owner keeps
# running (phase 3).
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(retainsWhileHidden);" in page_shown_branch
assert "disposeMountedWidgets();" not in page_shown_branch
def test_widgets_page_reads_cheap_list_and_reconciles_by_signature():
"""Widgets lifecycle phase 1: the page reads the passive ``GET /api/widgets``
projection — never the fat ``/api/extensions`` catalogue, which stays the
Skills page's read — paints the shell from the last known list before its
first await, and after every fetch compares an order-independent list
signature: unchanged → not one ``<article>`` is touched; changed → keyed
patch (``web/modules/widget_list.js`` holds the pure helpers). The same
sync runs on a visible ``extension_lifecycle`` event and on every WebSocket
(re)connect, never on a timer. Contextual failed-read Retry uses that same
reconcile; it never restores the former blanket Refresh/reset behavior."""
source = _widgets_js()
helpers = _read("web/modules/widget_list.js")
assert "apiClient.widgets()" in source
assert "apiClient.extensions()" not in source
assert "live.ui_tabs" not in source
assert "live?.ui_tabs" not in source
assert "from './widget_list.js'" in source
assert "export function widgetTabsSignature" in helpers
assert "export function planWidgetListPatch" in helpers
assert "const signature = widgetTabsSignature(tabs);" in source
assert "if (signature !== lastSignature) {" in source
assert "patchWidgetCards(list, lastTabs, tabs);" in source
assert "ctx.ws.on('extension_lifecycle', reconcileWidgetList);" in source
assert "ctx.ws.on('open', reconcileWidgetList);" in source
assert "setInterval(" not in source
# Entry paints the shell before the first await, then syncs.
assert source.index("paintShell(lastTabs);") < source.index("await syncWidgets(generation);")
# Q17 adds recovery from failed reads while preserving Q20's prohibition on
# blanket reset. It neither clears Stop nor introduces restart confirmation.
css = (REPO_ROOT / "web" / "style.css").read_text(encoding="utf-8")
card = _read("web/modules/widget_card.js")
for absent in ("widgets-refresh", "refreshBtn", "refreshWidgets", "confirmWidgetsRestart"):
assert absent not in source, absent
assert "confirmWidgetsRestart" not in card
assert "openConfirmDialog" not in card
assert "widgets-refresh" not in css
assert "actionsHtml" not in source
assert 'id="widgets-list-error"' in source
assert "retryButton.addEventListener('click', reconcileWidgetList);" in source
assert "listError.hidden = false;" in source
# `render()` takes no force flag: there is no path that clears every owner
# Stop and rebuilds every card while the page stays open.
assert "async function render() {" in source
assert "stoppedByOwner.clear()" not in source
# A vanished card's declarative session state and the owner's page-session Stop
# are evicted on BOTH removal paths: the keyed patch, and the rebuild branch the
# patch never sees (the last card leaving, or the first list arriving). Eviction
# runs after disposal, because a declarative disposer writes that snapshot as it
# goes. Without it, re-enabling the only skill restores values the owner never
# re-entered and keeps its card suppressed.
rebuild = source.split("// Rebuilding the shell destroys frames", 1)[1].split("renderShell(list, tabs);", 1)[0]
assert "await disposeMountedWidgets();" in rebuild
assert rebuild.index("await disposeMountedWidgets();") < rebuild.index("widgetSessionState.delete(key);")
assert "stoppedByOwner.delete(key);" in rebuild
patch = source.split("function patchWidgetCards(", 1)[1].split("for (const tab of nextTabs)", 1)[0]
assert "widgetSessionState.delete(key);" in patch and "stoppedByOwner.delete(key);" in patch
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 _framed_widget_sources(), (
"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
# The table cell renderer and its http(s)-only link guard moved to
# widget_chart.js (cycle-A fix round, ratchet room); the guard still exists.
assert "function safeTableHref" in _read("web/modules/widget_chart.js")
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 _framed_widget_sources()
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") + _read("web/modules/ui_primitives.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
framed = _framed_widget_sources()
assert "window.location.href" not in framed
assert "window.location.assign" not in framed
assert '<a class="btn btn-default" href' not in framed
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():
"""Both framed mounts — the ``kind: "iframe"`` route frame and the
``kind: "module"`` ``srcdoc`` frame — carry ONE decided capability set
(widgets lifecycle sprint, Q14=B / Q16=A): ``sandbox="allow-scripts
allow-pointer-lock allow-downloads"``, ``allow="autoplay; fullscreen;
clipboard-write"`` and ``allowfullscreen``. The route frame's former empty
sandbox is gone deliberately (its page is the skill's own, reviewed as
payload; the opaque origin still keeps the SPA's cookies and DOM away). The
tokens that would re-expose the SPA origin or widen the frame beyond the
decision never appear in the framed sources: no ``allow-same-origin`` (the
only token that re-exposes parent storage), no top navigation, popups,
forms (``form-action`` does not fall back to ``default-src``) or modals,
and no clipboard read. The module frame is a created ``<iframe>`` whose
document is assigned through the ``srcdoc`` PROPERTY (no attribute-escaping
round-trip of a module-sized payload); the route frame's URL goes in
through the ``src`` property the same way (never an interpolated
attribute string).
"""
source = _framed_widget_sources()
module = _read("web/modules/widget_module.js")
assert "export const WIDGET_FRAME_SANDBOX = 'allow-scripts allow-pointer-lock allow-downloads';" in module
assert "export const WIDGET_FRAME_ALLOW = 'autoplay; fullscreen; clipboard-write';" in module
assert "iframe.setAttribute('sandbox', WIDGET_FRAME_SANDBOX);" in module
assert "iframe.setAttribute('allow', WIDGET_FRAME_ALLOW);" in module
assert "iframe.setAttribute('allowfullscreen', '');" in module
assert 'sandbox=""' not in source, "the route iframe shares the module frame's capability set now"
assert "iframe.setAttribute('sandbox', 'allow-scripts');" not in source
assert "iframe.srcdoc = srcdoc;" in source
assert 'srcdoc="' not in source
assert "iframe.src = src;" in module
assert 'src="${src}"' not in source
for forbidden in (
"allow-same-origin",
"allow-top-navigation",
"allow-popups",
"allow-forms",
"allow-modals",
"clipboard-read",
):
assert forbidden not in source, forbidden
assert "render.kind === 'module'" in source
# The module document CSP: sources are ABSOLUTE (an opaque frame's 'self' is
# nothing), scripts run inline / from blob: / from this skill's module
# prefix with 'wasm-unsafe-eval', workers from blob:, passive image / media
# / font loads from data: / blob: / this skill's route prefix — and nothing
# else: no connect-src (falls to default-src 'none'; the parent bridge is the
# frame's one request path) and never plain 'unsafe-eval'.
assert "export function moduleFrameCsp(skill, origin = window.location.origin)" in module
assert "const prefix = `${origin}${extensionRoutePrefix(skill)}`;" in module
assert "default-src 'none'" in module
assert "script-src 'unsafe-inline' 'wasm-unsafe-eval' blob: ${prefix}module/" in module
assert "'worker-src blob:'" in module
assert "style-src 'unsafe-inline'" in module
assert "img-src data: blob: ${prefix}" in module
assert "media-src data: blob: ${prefix}" in module
assert "font-src data: blob: ${prefix}" in module
assert "const csp = moduleFrameCsp(tab.skill);" in module
assert "connect-src" not in source
assert "'unsafe-eval'" not in source
assert "window.OuroborosWidget = { fetch: request, onEvent, download, openExternal: (url) => openExternal(url) };" in source
assert "module widget fetch outside extension route prefix" in source
def test_widgets_frame_geometry_and_teardown_contract():
source = _framed_widget_sources()
style = _read("web/style.css")
assert "--widget-frame-height" in source
assert "height: var(--widget-frame-height, 320px);" in style
assert "type: 'ouro-widget-resize'" in source
assert "new ResizeObserver(report)" in source
assert "box.bottom - bodyTop + bodyBottomSpacing" in source
assert "fixedViewportBody" in source
assert 'scrolling="no"' not in source
assert "syncModuleFrameScrolling" not in source
assert "getPropertyValue('overflow-y')" in source
assert "getPropertyPriority('overflow-y')" in source
assert "style.setProperty('overflow-y', 'hidden', 'important')" in source
assert "style.setProperty('overflow-x'" not in source
assert source.index("setVerticalOverflowSuppressed(true);") < source.index("const report = () =>")
assert "setVerticalOverflowSuppressed(outerHeight <" in source
assert "setVerticalOverflowSuppressed(false);" in source
assert "nonce, WIDGET_FRAME_DEFAULT_HEIGHT, maxHeight, WIDGET_FRAME_BORDER_RESERVE," in source
assert source.index("<script>${resizeBridge}</script>") < source.index("<script>${escapeScript(moduleSource)}</script>")
assert "ouro-widget-dispose" in source
assert "if (iframe?.parentNode === mount) iframe.remove();" in source
assert "pendingRequests.forEach((controller) => controller.abort());" in source
assert "if (!isCurrent())" in source
assert "WIDGET_FRAME_MAX_HEIGHT = 8192" in source
assert "widget module request timed out" in source
assert source.index("moduleSource = await resp.text();") < source.index("clearTimeout(sourceTimeout);")
assert "widgetMountControllers.forEach((controller) => controller.abort());" in source
def test_widgets_framed_dispose_is_ordered_and_acknowledged():
"""Widgets lifecycle phase 2, both sides of the frame. Child
(``widget_frame.js``): on ``ouro-widget-dispose`` every registered hook
runs first — async hooks are awaited, the fetch bridge stays live for
them — then the bootstrap posts ``ouro-widget-disposed`` and only then
fails pending fetches (rejecting unsettled ones, erroring open body
streams) and removes its listener. Parent
(``widget_module.js``): the disposer posts the dispose message, keeps
``onMessage`` answering bridged fetches, and the abort → unlisten →
``iframe.remove()`` tail runs from ``finish`` on the acknowledgement or
after ``WIDGET_DISPOSE_ACK_TIMEOUT_MS`` (1 s, beside the 25 s request
timeout) — asynchronously, never blocking a page switch. The page keeps
one settle promise per key so a remount waits for the pending stop."""
child = _read("web/modules/widget_frame.js")
parent = _read("web/modules/widget_module.js")
jobs = _read("web/modules/widget_job.js")
page = _widgets_js()
assert "export const WIDGET_DISPOSE_ACK_TIMEOUT_MS = 1000;" in jobs
assert jobs.index("WIDGET_REQUEST_TIMEOUT_MS = 25000") < jobs.index("WIDGET_DISPOSE_ACK_TIMEOUT_MS = 1000")
# Child: hooks (awaited) → ack → reject pending → unlisten.
dispose_body = child.split("const dispose = async () =>", 1)[1].split("const onMessage", 1)[0]
assert "await Promise.allSettled(hooks.map((fn) => Promise.resolve().then(fn)));" in dispose_body
assert dispose_body.index("Promise.allSettled") < dispose_body.index("type: 'ouro-widget-disposed'")
assert dispose_body.index("type: 'ouro-widget-disposed'") < dispose_body.index("item.fail(new Error('widget disposed'))")
assert dispose_body.index("item.fail(new Error('widget disposed'))") < dispose_body.index("window.removeEventListener('message', onMessage);")
# The bridge answers during the hooks: frames are refused only once `disposed`,
# and the dispose message itself is honoured before that gate.
frames = child.split("const onMessage = (event) =>", 1)[1].split("const request =", 1)[0]
assert frames.index("if (msg.type === 'ouro-widget-dispose') {") < frames.index("if (disposed) return;")
assert frames.index("if (disposed) return;") < frames.index("if (msg.type !== 'ouro-widget-fetch-chunk') return;")
# Parent: the old synchronous tail is now the post-ack `finish`.
assert "if (msg.type === 'ouro-widget-disposed') {" in parent
tail = parent.split("const finish = () =>", 1)[1]
assert tail.index("pendingRequests.forEach((controller) => controller.abort());") < tail.index("window.removeEventListener('message', onMessage);")
assert tail.index("window.removeEventListener('message', onMessage);") < tail.index("if (iframe?.parentNode === mount) iframe.remove();")
assert "onDisposed = finish;" in tail
assert "setTimeout(finish, WIDGET_DISPOSE_ACK_TIMEOUT_MS)" in tail
assert "postMessage({ type: 'ouro-widget-dispose', nonce }, '*');" in tail
assert "if (disposing) return disposing;" in parent
# Page: one settle promise per key; a remount and the facade wait for it.
assert "const widgetDisposing = new Map();" in page
assert "if (settling) await settling;" in page
assert "await widgetDisposing.get(key);" in page
assert "return Promise.allSettled(Array.from(widgetDisposing.values()));" in page
# Cycle A, CA-1: a CHANGED card whose frame runs (revision / render change)
# follows the removed-card shape — the old <article> stands, marked and
# `stopping`, until the settle; the fresh card is inserted beside it and its
# mount waits on the same settle promise. Never `replaceWith` over a
# settling frame.
retire = page.split("function retireCard(card, settling) {", 1)[1].split("function patchWidgetCards", 1)[0]
assert "card.setAttribute('data-widget-removed', '');" in retire
assert "syncWidgetCardControls(card, 'stopping');" in retire
assert "settling.then(() => card.remove());" in retire
changed_branch = page.split("for (const tab of nextTabs) {", 1)[1].split("function mountTrackedTab", 1)[0]
assert "const settling = disposeWidgetByKey(key);" in changed_branch
assert "else if (!settling) card.replaceWith(fresh);" in changed_branch
assert "card.after(fresh);" in changed_branch
assert "retireCard(card, settling);" in changed_branch
# CA-3: one mount in flight per key — a second request for the SAME card
# joins it. CA-19 / A3-10: never a stale one (another card node, or a mount
# whose page generation moved on) — wait for it, then mount on your own.
assert "const widgetMounting = new Map();" in page
assert "if (inFlight && inFlight.card === card && inFlight.isCurrent()) return inFlight.promise;" in page
assert "? inFlight.promise.catch(() => {}).then(() => mountTabOnce(card, tab, key, isCurrent))" in page
assert "widgetMounting.set(key, { card, isCurrent, promise: mounting });" in page
assert "if (!isCurrent() || !card.isConnected) return;" in page
def test_widgets_launch_policy_controls_and_stop_suppression():
"""Widgets lifecycle phase 2, host side. Framed (module / route-iframe)
cards carry exactly one primary control (Start / Stop) plus a secondary
launch-policy menu built on the Skills card menu primitive; declarative
cards get neither. The effective policy is owner override > author
``render.start`` > kind default (``widget_card.js``, node-tested). A card
that is not to run shows a facade at the declared frame height through
the frame's own custom property. Owner Stop is remembered for the page
session only; Start, a policy change to Auto / Keep running, and a window
reload forget it. A vanished card is stopped in order and evicts its session
state. Phase 3: a FRAMED card whose effective policy is `retain` keeps its
frame mounted in the hidden page when the owner leaves (declarative cards
always dispose), its badge says "Keeps running", a lifecycle event while
hidden force-stops a kept frame whose skill left the list, and the window
reload still ends every frame with its window."""
page = _widgets_js()
card = _read("web/modules/widget_card.js")
style = _read("web/style.css")
assert "export function effectiveStartMode(tab, prefs)" in card
assert "export function isRetainedWidget(tab, prefs)" in card
assert "return isFramedWidget(tab) && effectiveStartMode(tab, prefs) === 'retain';" in card
assert "? 'Keeps running'" in card
assert "const retainsWhileHidden = (key) => isRetainedWidget(tabByKey(key), uiPreferences);" in page
assert "if (!keep?.(key)) disposeWidgetByKey(key);" in page
hidden_branch = page.split("async function stopVanishedRetainedWidgets() {", 1)[1].split("async function syncWidgets", 1)[0]
assert "if (widgetsVisible) return;" in hidden_branch
assert "kept.filter((key) => !live.has(key)).forEach(disposeWidgetByKey);" in hidden_branch
reconcile = page.split("function reconcileWidgetList() {", 1)[1].split("async function stopVanishedRetainedWidgets", 1)[0]
assert "stopVanishedRetainedWidgets();" in reconcile
assert reconcile.index("listDirty = true;") < reconcile.index("stopVanishedRetainedWidgets();")
assert "const KIND_DEFAULT_START = { declarative: 'auto', module: 'manual', iframe: 'manual' };" in card
assert "if (!isFramedWidget(tab)) return '';" in card
assert card.count("btn btn-primary") == 1
assert 'role="menuitemradio"' in card
assert '<dialog class="skills-card-menu-dialog ui-popup" role="menu"' in card
assert 'class="skills-card-menu-trigger"' in card
assert '<span class="ui-status" data-tone="neutral" data-widget-status hidden>' in card
# Owner-facing menu wording, not the enum name (CA-11).
assert "retain: 'Keep running'," in card
assert "(retain)" not in card
# Facade: never over a settling frame (CA-13); `icon` is a glyph — an
# identifier-like NAME (the `extension` default) falls back to the page glyph (CA-15).
assert "mount.querySelector('[data-widget-facade], iframe')" in card
assert "const ICON_NAME = /^[a-z][a-z0-9_-]*$/i;" in card
assert "setFrameHeight(mount.firstElementChild, frameHeight(tab.render || {}));" in card
# Closing a menu hands focus back to its trigger when it was inside (CA-16, WebKit).
assert "const binding = bindMenu(popover, {" in card
assert "anchor: trigger," in card
assert "binding.close({ restoreFocus: true })" in card
assert ".widgets-facade {" in style
assert "height: var(--widget-frame-height, 320px);" in style.split(".widgets-facade {", 1)[1].split("}", 1)[0]
assert ".widgets-card-controls .ui-status[data-tone]::before" in style
# An open policy menu is never painted under a sibling card (CA-14).
assert ".widgets-card:has(.skills-card-menu-dialog[open]) {" in style
# Widgets is not a migrated surface (DESIGN.md section 8): the phase-2/3 card
# controls / menu / facade rules keep the surface's literals, not type tokens;
# the shared `.ui-status[data-tone]` pair (section 4) is the one exception.
controls_css = style.split("/* Framed-card head controls", 1)[1].split(".widgets-card-source {", 1)[0]
for token in ("var(--type-", "var(--line-", "var(--text-meta)"):
assert token not in controls_css, token
# Page: policy gate, suppression, owner controls, whole-map persistence.
assert "const stoppedByOwner = new Set();" in page
assert "effectiveStartMode(tab, uiPreferences) !== 'manual'" in page
assert "&& !stoppedByOwner.has(widgetKey(tab));" in page
assert "if (isFramedWidget(tab) && !startsOnShow(tab)) await settleStopped(card, tab);" in page
assert "stoppedByOwner.add(widgetKey(tab));" in page
assert "stoppedByOwner.delete(widgetKey(tab));" in page
assert "apiClient.saveUiPreferences({ widget_start_mode: next })" in page
assert "const next = withWidgetStartMode(current, key, mode);" in page
# Launch-policy writes are serialized (AUD-13): the card applies after its write.
assert "let startModeWrites = Promise.resolve();" in page
assert "startModeWrites = write.catch(() => {});" in page
set_mode = page.split("async function setWidgetStartMode(key, mode) {", 1)[1].split("bindWidgetCardMenus", 1)[0]
assert set_mode.index("await write;") < set_mode.index("const card = liveCardFor(list, key);")
assert "bindWidgetCardMenus(list, setWidgetStartMode);" in page
assert "event.target.closest('[data-widget-power]')" in page
# Force-stop + eviction on a vanished card; the frame keeps its ack window.
removed_branch = page.split("for (const key of plan.removed) {", 1)[1].split("for (const tab of nextTabs) {", 1)[0]
assert "disposeWidgetByKey(key)" in removed_branch
assert "widgetSessionState.delete(key);" in removed_branch
assert "stoppedByOwner.delete(key);" in removed_branch
assert "if (settling) retireCard(card, settling);" in removed_branch
# A stopped framed card ends with its facade and Start (CA-13): on leave for
# every card not kept running, and while a card starts (idempotent facade).
page_shown_branch = page.split("window.addEventListener('ouro:page-shown'", 1)[1]
assert "if (card && tab && isFramedWidget(tab)) settleStopped(card, tab);" in page_shown_branch
start_widget = page.split("async function startWidget(card, tab, isCurrent) {", 1)[1].split("async function settleStopped", 1)[0]
assert "if (isFramedWidget(tab)) renderWidgetFacade(mount, tab);" in start_widget
assert "else if (isFramedWidget(tab)) await settleStopped(card, tab);" in start_widget
# CA-20: a mount under way for the key decides the body first — wait for it
# and re-check; never an empty body behind a mount that bailed.
settle = page.split("async function settleStopped(card, tab) {", 1)[1].split("async function stopWidgetByOwner", 1)[0]
assert "if (widgetMounting.has(key)) await widgetMounting.get(key).promise.catch(() => {});" in settle
assert "if (widgetDisposers.has(key) || !card.isConnected) return;" in settle
# Hidden reconcile force-stops vanished kept frames even mid-sync (CA-18).
assert "if (!widgetsVisible) stopVanishedRetainedWidgets();" in page
assert "localStorage" not in _framed_widget_sources()
def test_widgets_job_poll_retries_transient_transport_without_dropping_id():
source = _widgets_js()
assert "error.retryable = resp.status === 408" in source
assert "isRetryableWidgetError(err) && ticks < maxTicks" in source
assert "classifyWidgetJobStatus" in source
assert "invalid job status response" in source
assert "status[target] = 'loading';" in source
assert "schedule(pollJob, intervalMs);" in source
assert "delete componentState[`job:${key}`];" in source
def test_widgets_use_design_radius_tokens():
style = _read("web/ui.css")
block_start = style.index(".ui-control {")
block_end = style.index("}", block_start)
block = style[block_start:block_end]
assert "border-radius: var(--radius);" in block
assert "border-radius: 9px;" not in block
def test_widgets_cards_do_not_stretch_to_row_height():
"""Masonry packs unequal cards by absolute position. Phase 3: `layout()`
packs the cards in the page's explicit key order (`widget_order`), never the
DOM order, and writes the plan back only as narrow custom properties
(`--masonry-w/-x/-y` per card, `--masonry-h` on the list) that one static
rule set in web/style.css applies; the generated per-container `<style>`
with `:nth-child` rules is gone, and `applyMasonry` returns an idempotent
disposer for its three observers and the pending frame."""
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 "const relayout = () => applyMasonry(list, { order: currentWidgetOrder() });" 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
# Custom properties in, static CSS out; no generated stylesheet, no DOM id.
for name in ("--masonry-w", "--masonry-x", "--masonry-y", "--masonry-h"):
assert f"setProperty('{name}'" in masonry, name
for gone in ("createElement('style')", "document.head", "getElementById", "data-masonry-id", "masonryId", "nth-child", "textContent"):
assert gone not in masonry, gone
assert "resizeObserver.disconnect();" in masonry
assert "itemResizeObserver.disconnect();" in masonry
assert "mutationObserver.disconnect();" in masonry
assert "cancelAnimationFrame(frame)" 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 "height: var(--masonry-h, auto);" in widgets_block
card_block = css.split("height: var(--masonry-h, auto);", 1)[1].split(".widgets-card {", 1)[1].split("}", 1)[0]
assert "width: var(--masonry-w, auto);" in card_block
assert "transform: translate(var(--masonry-x, 0px), var(--masonry-y, 0px));" in card_block
# `widgets-card-span-2` is the JS span signal only (masonry reads the class);
# the inert `grid-column: span 2` rules from the grid era are gone (CA-9).
assert ".widgets-card-span-2" not 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_widget_fault_status_wraps_inside_narrow_card():
style = _read("web/style.css")
root = _read("web/ui.css").split(":root {", 1)[1].split("}", 1)[0]
controls = style.split(".widgets-card-controls {", 1)[1].split("}", 1)[0]
status = style.split(".widgets-card-controls .ui-status:not([hidden]) {", 1)[1].split("}", 1)[0]
assert "--widget-fault-status-max-width: min(240px, 35vw);" in root
assert "flex-wrap: wrap;" in controls
assert "min-width: 0;" in controls
assert "min-width: 0;" in status
assert "max-width: var(--widget-fault-status-max-width);" in status
assert "white-space: normal;" in status
assert "overflow-wrap: anywhere;" in status
def test_widgets_card_order_is_owner_ui_preference():
"""The reorder handles live in ``widget_reorder.js``; the card markup and
the preference write stay in the page host. Phase 3: a reorder (drag or
keys) is a pure move in the KEY order (``moveWidgetKey``) handed back to the
page, which re-sorts, relayouts through masonry and persists — no
``<article>`` is ever moved, so a running frame never reloads on reorder.
Disclosed residual: the Tab/focus order follows the DOM and may differ from
the visible order until a window reload rebuilds the cards."""
source = _widgets_js()
reorder = _read("web/modules/widget_reorder.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 "from './widget_reorder.js'" in source
assert "export function sortTabsByWidgetOrder" in reorder
assert "originalIndex" in reorder
assert "return a.originalIndex - b.originalIndex;" in reorder
assert "Move widget: drag or use arrow keys" in source
assert "handle.addEventListener('keydown'" in reorder
assert "event.key === 'ArrowUp'" in reorder
assert "apiClient.uiPreferences()" in source
assert "apiClient.saveUiPreferences({ widget_order: normalized })" in source
assert "export function moveWidgetKey(order, key, toIndex)" in reorder
assert "export function bindWidgetCardReorder(list, currentOrder, onOrderChange)" in reorder
assert "bindWidgetCardReorder(list, currentWidgetOrder, persistWidgetOrder);" in source
for moved in (".before(", ".after(", ".prepend(", ".append(", "insertBefore", "appendChild", "replaceWith"):
assert moved not in reorder, moved
# The keyed patch inserts and replaces nodes but never moves one.
assert "anchor.after(" not in source
assert "list.prepend(" not in source
assert "previousLiveCard" not 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()
framed = _framed_widget_sources()
assert "render.kind === 'inline_card'" not in framed
assert "skill-widget-weather" not in framed
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 _framed_widget_sources()
assert "data-widget-kanban-key" in source
def test_widgets_forms_charts_and_kanban_keep_host_owned_contracts():
"""The chart helpers (config, finite-value coercion, accessible data table)
and the shared dotted-path reader moved unchanged into
``widget_chart.js`` (phase 3 made room in widgets.js); the table cell
renderer with its number formatter and http(s)-only link guard followed in
the cycle-A fix round."""
source = _widgets_js()
helper = _read("web/modules/ui_helpers.js") + _read("web/modules/ui_primitives.js")
chart = _read("web/modules/widget_chart.js")
assert "renderSafeField(" in source
assert "collectSafeFieldValues(" in source
assert "includePasswords: false" in source
assert "pendingActions.has(key)" in source
assert "from './widget_chart.js'" in source
assert "export function getPath(root, path, fallback = '')" in chart
assert "export function formatNumber(value, precision)" in chart
assert "export function renderTableCell(row, column)" in chart
assert "['http:', 'https:'].includes(parsed.protocol)" in chart
assert "spanGaps: false" in chart
assert "export function finiteChartValue" in chart
assert "data.map(finiteChartValue)" in chart
assert "aria-label=" in source
assert "renderChartDataTable(config, label, !chartAvailable)" in source
assert "export function renderChartDataTable" in chart
assert "data-widget-kanban-move" in source
assert "widget-kanban-empty" in source
assert "listen('change', (event) =>" in source
assert "select?.closest('[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") + _read("web/modules/ui_primitives.js")
style = _read("web/style.css")
assert "function widgetTone" not in _framed_widget_sources()
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
def test_widgets_module_bridge_is_one_streaming_grammar():
"""Widgets lifecycle phase 4: the module frame has ONE parent-mediated I/O
grammar on the existing nonce. Child → parent: ``ouro-widget-fetch``,
``ouro-widget-fetch-abort``, ``ouro-widget-events {subscribe|unsubscribe}``
and the ``ouro-widget-disposed`` ack. Parent → child: ``ouro-widget-fetch-chunk``
frames (``headers`` with status/statusText/every header first, one ``data``
frame per body chunk as a transferred ArrayBuffer, then ``end``; ``error`` on
failure) and ``ouro-widget-event`` for the skill's ``ws_prefix`` WebSocket
messages. The child rebuilds a real ``Response`` over a ``ReadableStream``
(binary by default; null body for HEAD/204/205/304). Negative pins: the old
string ``-fetch-result`` path and its ``r.text()`` are gone with no alias,
the frame opens no ``EventSource``/``WebSocket`` of its own, and no default
timeout constant is applied to a bridged fetch — the only timer in the relay
is the author's opt-in ``init.timeoutMs`` (declarative requests and the
module source load keep ``WIDGET_REQUEST_TIMEOUT_MS``)."""
child = _read("web/modules/widget_frame.js")
parent = _read("web/modules/widget_module.js")
page = _widgets_js()
framed = _framed_widget_sources()
# Child grammar and Response construction.
for name in (
"type: 'ouro-widget-fetch'", "type: 'ouro-widget-fetch-abort'", "type: 'ouro-widget-events'",
"'ouro-widget-fetch-chunk'", "'ouro-widget-event'", "type: 'ouro-widget-disposed'",
"type: 'ouro-widget-error'",
):
assert name in child, name
assert "new ReadableStream({" in child
assert "resolve(new Response(stream, {" in child
assert "const nullBody = method === 'HEAD' || [204, 205, 304].includes(Number(msg.status));" in child
assert "headers: Array.from(new Headers(init.headers || {}))," in child
assert "timeoutMs: init.timeoutMs ?? null," in child
assert "signal?.addEventListener('abort', onAbort, { once: true });" in child
assert "const onEvent = (callback) =>" in child
assert "post({ type: 'ouro-widget-events', op: 'subscribe' });" in child
assert "post({ type: 'ouro-widget-events', op: 'unsubscribe' });" in child
assert "window.fetch = request;" in child
# Parent relay: prefix check, headers → data (transferred) → end, error; abort;
# event forwarding under the card's ws_prefix through the page's handler Set.
relay = parent.split("const relayFetch = async (msg) =>", 1)[1].split("const onMessage = (event) =>", 1)[0]
assert "module widget fetch outside extension route prefix" in relay
# The prefix is checked once, before the request. A followed redirect would
# carry the frame's method, body, headers and the owner's session wherever the
# hop points, past that check, so the relay refuses the hop instead.
assert "redirect: 'error'," in relay
assert relay.index("phase: 'headers'") < relay.index("phase: 'data'") < relay.index("phase: 'end'") < relay.index("phase: 'error'")
assert "statusText: r.statusText, headers: Array.from(r.headers)" in relay
assert "const reader = r.body?.getReader();" in relay
assert "const chunk = bridgeChunkBuffer(value);" in relay
assert "frame({ phase: 'data', chunk }, [chunk]);" in relay
assert "export function bridgeChunkBuffer(view)" in child
assert "pendingRequests.get(msg.id)?.abort();" in parent
assert "if (msg.type === 'ouro-widget-fetch') relayFetch(msg);" in parent
assert "const wsPrefix = String(tab.ws_prefix || '').trim();" in parent
assert "if (!wsPrefix || !type.startsWith(wsPrefix)) return;" in parent
assert "post({ type: 'ouro-widget-event', event: type.slice(wsPrefix.length), data: msg.data ?? {} });" in parent
assert "if (msg.op === 'subscribe') messageHandlers?.add(onWsMessage);" in parent
assert "else if (msg.op === 'unsubscribe') messageHandlers?.delete(onWsMessage);" in parent
assert "return mountModuleWidget(mount, tab, render, mountSignal, widgetMessageHandlers);" in page
# Dispose tail: open streams aborted with the pending requests, forwarding dropped.
tail = parent.split("const finish = () =>", 1)[1]
assert tail.index("pendingRequests.forEach((controller) => controller.abort());") < tail.index("messageHandlers?.delete(onWsMessage);")
assert tail.index("messageHandlers?.delete(onWsMessage);") < tail.index("window.removeEventListener('message', onMessage);")
# Negative pins: no string path, no alias, no second transport, no default bridge timeout.
assert "ouro-widget-fetch-result" not in framed
assert ".text()" not in relay
assert "WIDGET_REQUEST_TIMEOUT_MS" not in relay
assert "withWidgetRequestTimeout" not in relay
assert relay.count("setTimeout(") == 1
assert "Number.isFinite(timeoutMs) && timeoutMs > 0" in relay
for forbidden in ("EventSource(", "WebSocket(", "XMLHttpRequest", "setTimeout("):
assert forbidden not in child, forbidden
jobs = _read("web/modules/widget_job.js")
assert "BRIDGE" not in jobs and "bridge" not in jobs.split("Ordered stop", 1)[0]
def test_widgets_module_frame_faults_reach_the_card_status_slot():
"""A module widget's frame has a bounded fault channel, and the height cap is
readable from the DOM.
In-frame script errors, unhandled rejections and CSP refusals are posted as
one ``ouro-widget-error`` message per distinct kind+message (deduplicated,
capped, clipped) through the existing nonce-bound ``post`` helper, and the
parent writes them into the card's own ``[data-widget-status]`` slot. The
lifecycle state is deliberately untouched: the frame is still mounted, so
``syncWidgetCardControls`` is NOT called with an error state, which would
flip a live Stop button back to Start. All three listeners are registered on
``window`` (``securitypolicyviolation`` bubbles there, and the child bridge
runs in hosts with no ``document``) and all three are removed in the single
``dispose()``. The two height-cap attributes are stamped BEFORE the
``nextHeight === appliedHeight`` early return, which is exactly the case they
exist to explain."""
child = _read("web/modules/widget_frame.js")
parent = _read("web/modules/widget_module.js")
card = _read("web/modules/widget_card.js")
# Child: three window listeners, one bounded post, removed on dispose.
for listener in ("'error'", "'unhandledrejection'", "'securitypolicyviolation'"):
assert f"window.addEventListener({listener}" in child, listener
assert f"window.removeEventListener({listener}" in child, listener
assert "document.addEventListener('error'" not in child
assert "if (faultCount >= 10 || seenFaults.has(key)) return;" in child
assert ".slice(0, 500)" in child and ".slice(0, 200)" in child
# Parent: routed to the status slot only; no lifecycle flip.
assert "msg.type === 'ouro-widget-error'" in parent
assert "setWidgetCardFault(mount.closest('[data-widget-key]')" in parent
assert "syncWidgetCardControls(card, 'error'" not in parent
assert "export function setWidgetCardFault(card, text)" in card
assert "status.dataset.tone = 'error';" in card
# Ordering regression pin: the stamps precede the early return.
assert parent.index("widgetFrameCapped") < parent.index("nextHeight === appliedHeight")