The audit trail viewer landed last session — a tab-toggled page with a DSL table and a pages-event-trail hosted panel, eight payload-variant renderers, and correlation tracking. All untested. Issue #114 was the vitest gap; #115 was the filter sync between tabs.

The portal resolution chain

Vitest config existed. Vitest itself didn’t — yarn install failed because two transitive @casehubio packages (pages-schema, yaml-core) weren’t declared as portal resolutions in package.json. Adding those two surfaced a third (pages-property-palette). Adding that surfaced twenty-five more.

The Yarn PnP model doesn’t resolve transitive @casehubio packages from the npm registry — every internal package reference in the dependency tree needs an explicit portal resolution pointing to the vendored .casehub-packages/ directory. I wrote a script to diff the full set of @casehubio references across all portal packages against the declared resolutions. Twenty-eight were missing. After adding them all, yarn install succeeded for the first time on this project.

Testing TemplateResults directly

The renderers return lit TemplateResult objects — html tagged template literals with strings and values arrays. The conventional approach is to render to a jsdom DOM and query elements. That requires element registration, lifecycle management, and a full DOM setup for what are essentially pure functions: data in, template out.

I went with direct TemplateResult inspection instead. The strings array holds the static template fragments; values holds the interpolated expressions. A recursive walk of strings and values flattens the entire template tree into a single string — good enough for toContain() assertions on CSS classes, content text, and structural elements.

It broke on the first real test. The renderStateChange function maps over changedCapabilities inside the template:

${event.changedCapabilities.map(cap => html`
  <tr><td>${cap}</td>...
`)}

Lit’s html doesn’t inline .map() results — it produces an Array<TemplateResult> in the values array. My traversal checked for strings and values properties but never checked for arrays. The mapped content was silently skipped; tests passed with empty assertions. Adding if (Array.isArray(result)) return result.map(collectText).join('') to the recursive flattener fixed it — all 23 tests green.

Bridging DSL filters to hosted panels

The audit page has two tabs: Table (standard DSL table with filter groups) and Trail (hostPanel("event-trail", { endpoint: "..." })). The selectors use the DSL filter pipeline — filter: { enabled: true, group: "audit" }. The table listens via filter: { listening: true, group: "audit" }. The Trail doesn’t participate in the DSL filter pipeline at all — it fetches from its own endpoint and has its own chip/entity/date filters.

Moving the selectors above the tabs() call was the easy part — the Table continues listening to the “audit” filter group. But the Trail needs a bridge.

I explored three approaches. Template variables (#{filter.eventType} in the endpoint URL) won’t work — hostPanel defers configure() until all template variables resolve, so an unset filter would prevent the Trail from ever rendering. Data property binding (data on the Trail) isn’t reactive from the DSL layer.

The approach that works: a document-level pages-filter event listener. The runtime dispatches pages-filter from selectors, which bubbles up through the DOM. A nineteen-line module listens on document, tracks filter state in a Map, rebuilds the Trail endpoint URL with query params, and calls syncEndpoint(). The REST endpoint already accepts eventType, deviceId, correlationId, from, and to as query parameters — the bridge just constructs the URL.

const filters = new Map<string, string>();

function onFilter(e: Event) {
  const { columnId, value, reset, group } = (e as CustomEvent).detail;
  if (group !== 'audit') return;
  if (reset) filters.delete(columnId);
  else filters.set(columnId, value);

  const trail = document.querySelector('blocks-event-trail') as
    HTMLElement & { endpoint: string; syncEndpoint(): void } | null;
  if (!trail) return;

  const params = new URLSearchParams({ limit: '500' });
  for (const [k, v] of filters) params.set(k, v);
  trail.endpoint = `/api/bridge/audit?${params}`;
  trail.syncEndpoint();
}

document.addEventListener('pages-filter', onFilter);

Imported as a side-effect module from audit.ts — no custom element registration, no lifecycle management. The listener is harmless on non-audit pages (no blocks-event-trail element to find, no “audit” group events to match).

Upstream drift

The Maven build had four pre-existing compilation errors, all from upstream SNAPSHOT changes that hadn’t been absorbed. TemporalSimulationDriver.state() was renamed to lifecycle().currentState() in the platform simulation library. Three test classes referenced deleted production classes — IoTDeviceMcpTool (replaced by DefaultIoTDeviceApi), KpiResource (replaced by @McpDomain SPIs), and scenario descriptor classes removed from pages-scenario.

The fixes were mechanical — two method renames, three dead test deletions. But they surfaced a deeper issue: casehub-engine-persistence-memory at compile scope in the webapp pom causes CDI ambiguity with three CurrentPrincipal providers and three PlanItemStore providers. The memory module’s PersistenceMemoryBeans produces @Default beans that compete with the JPA and work-runtime implementations. That needs @Alternative @Priority(100) upstream — the same pattern this repo already uses for casehub-iot-bridge-persistence-memory.

What this opens up

Twenty-eight tests and a functioning vitest pipeline means frontend TypeScript in this repo now has the same test-before-commit discipline as the Java side. The collectText technique generalises to any lit template testing — no DOM setup, no element registration, no jsdom lifecycle. And the filter bridge pattern (pages-filter event → endpoint URL injection → syncEndpoint()) works for any hostPanel component that loads data from a REST endpoint.


<
Previous Post
Three Things Platform Alignment Actually Buys You
>
Next Post
When Your Agent Judges Itself