ComponentSnapshot.java
package org.vaadin.addons.dramafinder.agent;
import java.util.List;
import com.microsoft.playwright.Page;
/**
* Produces a compact, human- and agent-readable summary of the Vaadin
* components currently present on a page.
* <p>
* Instead of dumping a full accessibility tree (which is large and noisy for
* Vaadin's shadow DOM and teleported overlays), this walks the light DOM for
* {@code vaadin-*} elements and emits one terse line per meaningful component,
* e.g.:
* <pre>
* vaadin-grid — 12 rows, columns [Date, Amount, Status], 1 selected
* vaadin-combo-box "Category" = "Travel"
* notification: "Saved"
* vaadin-dialog-overlay open — "Confirm"
* </pre>
* The result is orders of magnitude smaller than an accessibility-tree dump and
* captures exactly the state an agent needs to reason about the UI. It is also
* embedded in failure reports written by {@link AgentReporting}.
*/
public final class ComponentSnapshot {
private ComponentSnapshot() {
}
// Walks the DOM in the browser and returns one string per component.
// @formatter:off
private static final String SNAPSHOT_SCRIPT = """
() => {
const out = [];
// Structural / internal tags we never report on their own.
const SKIP = new Set([
'vaadin-grid-cell-content','vaadin-grid-column','vaadin-grid-column-group',
'vaadin-grid-sorter','vaadin-grid-tree-toggle','vaadin-grid-flow-selection-column',
'vaadin-grid-selection-column','vaadin-combo-box-item','vaadin-item',
'vaadin-select-item','vaadin-list-box','vaadin-menu-bar-item','vaadin-menu-bar-button',
'vaadin-context-menu-item','vaadin-tab','vaadin-notification-container',
'vaadin-dev-tools','vaadin-connection-indicator','vaadin-overlay','vaadin-scroller',
'vaadin-horizontal-layout','vaadin-vertical-layout','vaadin-form-layout',
'vaadin-form-item','vaadin-app-layout','vaadin-split-layout','vaadin-icon',
'vaadin-avatar-group'
]);
// Overlays whose descendants are just list items, not real state.
const ITEM_OVERLAYS = 'vaadin-combo-box-overlay,vaadin-multi-select-combo-box-overlay,'
+ 'vaadin-select-overlay,vaadin-menu-bar-overlay,vaadin-context-menu-overlay';
const quote = (v) => (v === undefined || v === null || v === '') ? '' : ' = "' + v + '"';
const labelOf = (el) => (el.label || el.getAttribute('aria-label') || '').trim();
const els = Array.from(document.querySelectorAll('*'))
.filter(e => e.tagName.toLowerCase().startsWith('vaadin-'));
for (const el of els) {
const tag = el.tagName.toLowerCase();
if (SKIP.has(tag)) continue;
// Skip items nested inside selection overlays.
if (el.closest(ITEM_OVERLAYS) && !el.matches(ITEM_OVERLAYS)) continue;
// Skip anything living inside a grid (cells, editors) except the grid itself.
const grid = el.closest('vaadin-grid,vaadin-tree-grid');
if (grid && grid !== el) continue;
const label = labelOf(el);
const named = label ? ' "' + label + '"' : '';
if (tag === 'vaadin-grid' || tag === 'vaadin-tree-grid') {
const rows = el._flatSize ?? el._effectiveSize ?? el.size ?? 0;
const headers = [];
const sr = el.shadowRoot;
if (sr) {
sr.querySelectorAll('thead th').forEach(th => {
const slot = th.querySelector('slot');
if (!slot || !slot.assignedNodes) return;
const txt = slot.assignedNodes().map(n => n.textContent || '').join('').trim();
if (txt) headers.push(txt);
});
}
const selected = (el.selectedItems || []).length;
out.push(tag + named + ' — ' + rows + ' rows, columns ['
+ headers.join(', ') + ']' + (selected ? ', ' + selected + ' selected' : ''));
continue;
}
if (tag === 'vaadin-virtual-list') {
const items = (el.items || []).length;
out.push(tag + named + ' — ' + items + ' items');
continue;
}
if (tag === 'vaadin-notification-card') {
const txt = (el.textContent || '').replace(/\\s+/g, ' ').trim();
if (txt) out.push('notification: "' + txt + '"');
continue;
}
if (tag.endsWith('-overlay')) {
const opened = el.opened || el.hasAttribute('opened');
if (!opened) continue;
const header = (el.headerTitle || '').trim()
|| (el.querySelector('[slot="title"]')?.textContent || '').trim();
out.push(tag + ' open' + (header ? ' — "' + header + '"' : ''));
continue;
}
const flags = [];
if (el.disabled) flags.push('disabled');
if (el.readonly) flags.push('read-only');
if (el.required) flags.push('required');
if (el.invalid) flags.push('invalid');
const flagStr = flags.length ? ' (' + flags.join(', ') + ')' : '';
if (tag === 'vaadin-checkbox') {
out.push(tag + named + ' = ' + (el.checked ? 'checked' : 'unchecked') + flagStr);
continue;
}
if (tag === 'vaadin-checkbox-group' || tag === 'vaadin-radio-group') {
const val = Array.isArray(el.value) ? el.value.join(', ') : (el.value || '');
out.push(tag + named + quote(val) + flagStr);
continue;
}
if (tag === 'vaadin-button') {
const txt = (el.textContent || '').replace(/\\s+/g, ' ').trim();
if (txt) out.push('vaadin-button "' + txt + '"' + flagStr);
continue;
}
// Generic field-like components expose a value / label.
if ('value' in el || label) {
let val = el.value;
if (Array.isArray(val)) val = val.join(', ');
out.push(tag + named + quote(val) + flagStr);
}
}
return out;
}
""";
// @formatter:on
/**
* Capture a compact snapshot of all meaningful Vaadin components on the
* page.
*
* @param page the Playwright page to inspect
* @return a multi-line summary, one component per line (empty string when
* no components are found)
*/
@SuppressWarnings("unchecked")
public static String capture(Page page) {
Object result = page.evaluate(SNAPSHOT_SCRIPT);
if (!(result instanceof List<?> list) || list.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder();
for (Object line : list) {
if (line != null) {
sb.append(line).append(System.lineSeparator());
}
}
return sb.toString().stripTrailing();
}
}