feat(mcp): add helpers.dumpDom() to exec_code context
- new dom-helpers.ts exports dumpDom(page, selector?) that evaluates a
browser-side tree walker returning a lean DomNode structure
- filters ignored tags (svg, script, path, etc.) and hidden elements
- captures role, data-testid, data-qa, data-action, data-element-id,
id, type, name, href (relativized), checked, disabled, and text
- prunes single-child non-significant divs; discards empty non-sig nodes
- code-executor wraps dumpDom in a pageHelpers object passed as helpers
param so user scripts can call helpers.dumpDom() or helpers.dumpDom('main')
- 29 unit tests covering all behaviours via fake DOM builder
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { parse } from 'acorn';
|
||||
import type { Page, BrowserContext } from 'playwright';
|
||||
import { dumpDom } from './dom-helpers';
|
||||
|
||||
export interface ExecResult {
|
||||
result: unknown;
|
||||
@@ -15,7 +16,7 @@ export class CodeExecutorService {
|
||||
* to parse it with acorn. Throws BadRequestException if it is not valid JS.
|
||||
*/
|
||||
validate(code: string): void {
|
||||
const wrapped = `async function __validate__(page, context) { ${code} }`;
|
||||
const wrapped = `async function __validate__(page, context, helpers) { ${code} }`;
|
||||
try {
|
||||
parse(wrapped, { ecmaVersion: 2022 });
|
||||
} catch (err) {
|
||||
@@ -29,10 +30,11 @@ export class CodeExecutorService {
|
||||
*/
|
||||
async execute(page: Page, context: BrowserContext, code: string): Promise<ExecResult> {
|
||||
try {
|
||||
const pageHelpers = { dumpDom: (selector?: string) => dumpDom(page, selector) };
|
||||
// eslint-disable-next-line no-new-func
|
||||
const fn = new Function('page', 'context', `return (async (page, context) => { ${code} })(page, context)`);
|
||||
const fn = new Function('page', 'context', 'helpers', `return (async (page, context, helpers) => { ${code} })(page, context, helpers)`);
|
||||
this.logger.debug('Executing user code');
|
||||
const result = await fn(page, context);
|
||||
const result = await fn(page, context, pageHelpers);
|
||||
return { result };
|
||||
} catch (err) {
|
||||
throw new InternalServerErrorException(`Code execution failed: ${(err as Error).message}`, { cause: err });
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { Page } from 'playwright';
|
||||
|
||||
export interface DomNode {
|
||||
tag: string;
|
||||
role?: string;
|
||||
testid?: string;
|
||||
qa?: string;
|
||||
action?: string;
|
||||
elementId?: string;
|
||||
id?: string;
|
||||
type?: string;
|
||||
name?: string;
|
||||
text?: string;
|
||||
href?: string;
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
children: DomNode[];
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dumps a logical component tree of the live DOM scoped to `rootSelector`.
|
||||
* Filters out decorative/layout noise — keeps only structural landmarks,
|
||||
* interactive elements, and elements with semantic attributes
|
||||
* (role, data-testid, data-qa, data-action, data-element-id).
|
||||
*
|
||||
* Useful for debugging Playwright selectors without screenshotting.
|
||||
*/
|
||||
export async function dumpDom(page: Page, rootSelector = 'body'): Promise<DomNode> {
|
||||
return page.evaluate(
|
||||
([sel, maxDepth]) => {
|
||||
const root = document.querySelector(sel as string);
|
||||
if (!root) return { tag: 'ERROR', text: `selector not found: ${sel}`, children: [] };
|
||||
|
||||
const STRUCTURAL_TAGS = new Set([
|
||||
'BODY', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE', 'SECTION',
|
||||
'FORM', 'DIALOG', 'DETAILS', 'SUMMARY', 'TABLE', 'THEAD', 'TBODY',
|
||||
'TR', 'FIELDSET', 'LEGEND',
|
||||
]);
|
||||
|
||||
const INTERACTIVE_TAGS = new Set([
|
||||
'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL',
|
||||
'TH', 'TD',
|
||||
]);
|
||||
|
||||
const IGNORED_TAGS = new Set(['SCRIPT', 'STYLE', 'SVG', 'PATH', 'DEFS', 'USE', 'CIRCLE', 'RECT', 'POLYGON', 'POLYLINE', 'LINE', 'ELLIPSE', 'G', 'CLIPPATH', 'IMAGE']);
|
||||
|
||||
function trimText(el: Element): string | undefined {
|
||||
const t = (el as HTMLElement).innerText?.trim() ?? el.textContent?.trim() ?? '';
|
||||
// Only include if short enough to be meaningful, not a dump of all child text
|
||||
const ownText = Array.from(el.childNodes)
|
||||
.filter(n => n.nodeType === Node.TEXT_NODE)
|
||||
.map(n => n.textContent?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const candidate = ownText || t;
|
||||
return candidate.length > 0 ? candidate.substring(0, 80) : undefined;
|
||||
}
|
||||
|
||||
function isVisible(el: Element): boolean {
|
||||
const s = window.getComputedStyle(el);
|
||||
return s.display !== 'none' && s.visibility !== 'hidden' && (el as HTMLElement).offsetParent !== null;
|
||||
}
|
||||
|
||||
function isSignificant(el: Element): boolean {
|
||||
if (STRUCTURAL_TAGS.has(el.tagName)) return true;
|
||||
if (INTERACTIVE_TAGS.has(el.tagName)) return true;
|
||||
if (el.getAttribute('role')) return true;
|
||||
if (el.getAttribute('data-testid')) return true;
|
||||
if (el.getAttribute('data-qa')) return true;
|
||||
if (el.getAttribute('data-action')) return true;
|
||||
if (el.getAttribute('data-element-id')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function build(el: Element, depth: number): DomNode | null {
|
||||
if (IGNORED_TAGS.has(el.tagName)) return null;
|
||||
if (!isVisible(el)) return null;
|
||||
|
||||
const significant = isSignificant(el);
|
||||
const childResults: DomNode[] = [];
|
||||
|
||||
if (depth < (maxDepth as number)) {
|
||||
for (const child of Array.from(el.children)) {
|
||||
const node = build(child, depth + 1);
|
||||
if (node) childResults.push(node);
|
||||
}
|
||||
} else if (el.children.length > 0) {
|
||||
return significant
|
||||
? { tag: el.tagName.toLowerCase(), children: [], truncated: true }
|
||||
: null;
|
||||
}
|
||||
|
||||
// If not significant and no meaningful children, discard
|
||||
if (!significant && childResults.length === 0) return null;
|
||||
|
||||
// If not significant but has exactly one child, pass through (unwrap)
|
||||
if (!significant && childResults.length === 1) return childResults[0];
|
||||
|
||||
// If not significant but has children, keep as anonymous group only if > 1 child
|
||||
if (!significant) return { tag: el.tagName.toLowerCase(), children: childResults };
|
||||
|
||||
const node: DomNode = {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
children: childResults,
|
||||
};
|
||||
|
||||
const role = el.getAttribute('role');
|
||||
if (role) node.role = role;
|
||||
|
||||
const testid = el.getAttribute('data-testid');
|
||||
if (testid) node.testid = testid;
|
||||
|
||||
const qa = el.getAttribute('data-qa');
|
||||
if (qa) node.qa = qa;
|
||||
|
||||
const action = el.getAttribute('data-action');
|
||||
if (action) node.action = action;
|
||||
|
||||
const elementId = el.getAttribute('data-element-id');
|
||||
if (elementId) node.elementId = elementId;
|
||||
|
||||
const id = el.id;
|
||||
if (id) node.id = id;
|
||||
|
||||
const type = (el as HTMLInputElement).type;
|
||||
if (type && type !== 'submit' && el.tagName !== 'BUTTON') node.type = type;
|
||||
|
||||
const name = (el as HTMLInputElement).name;
|
||||
if (name) node.name = name;
|
||||
|
||||
const href = (el as HTMLAnchorElement).href;
|
||||
if (href && el.tagName === 'A') node.href = href.replace(window.location.origin, '');
|
||||
|
||||
if ('checked' in el) node.checked = (el as HTMLInputElement).checked;
|
||||
if ((el as HTMLButtonElement).disabled) node.disabled = true;
|
||||
|
||||
const text = trimText(el);
|
||||
if (text) node.text = text;
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
const result = build(root, 0);
|
||||
return result ?? { tag: 'empty', children: [] };
|
||||
},
|
||||
[rootSelector, 12] as [string, number],
|
||||
);
|
||||
}
|
||||
|
||||
export const helpers = { dumpDom };
|
||||
export type Helpers = typeof helpers;
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Unit tests for dumpDom.
|
||||
*
|
||||
* We use a lightweight fake DOM rather than jsdom to avoid ESM-dependency
|
||||
* transform issues. Each fake element is a plain JS object that duck-types
|
||||
* the DOM API used by the dumpDom evaluate payload. The fake `page`
|
||||
* captures the evaluate callback and runs it via new Function() with the
|
||||
* fake globals injected as named parameters.
|
||||
*/
|
||||
|
||||
import { dumpDom, DomNode } from '../src/code-executor/dom-helpers';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake DOM builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FakeText {
|
||||
nodeType: 3;
|
||||
textContent: string;
|
||||
}
|
||||
|
||||
interface FakeEl {
|
||||
tagName: string;
|
||||
children: FakeEl[];
|
||||
childNodes: FakeText[];
|
||||
offsetParent: object | null;
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
href: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
innerText: string;
|
||||
textContent: string;
|
||||
getAttribute(name: string): string | null;
|
||||
_display: string;
|
||||
_visibility: string;
|
||||
}
|
||||
|
||||
type ElAttrs = Partial<{
|
||||
role: string;
|
||||
'data-testid': string;
|
||||
'data-qa': string;
|
||||
'data-action': string;
|
||||
'data-element-id': string;
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
href: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
style: string;
|
||||
}>;
|
||||
|
||||
function el(tag: string, attrs: ElAttrs = {}, ...children: (FakeEl | string)[]): FakeEl {
|
||||
const ownTextNodes: FakeText[] = children
|
||||
.filter((c): c is string => typeof c === 'string')
|
||||
.map(t => ({ nodeType: 3, textContent: t }));
|
||||
const childEls = children.filter((c): c is FakeEl => typeof c !== 'string');
|
||||
const deepText = children.map(c => (typeof c === 'string' ? c : c.innerText)).join('');
|
||||
|
||||
const style = attrs.style ?? '';
|
||||
const display = /display\s*:\s*none/.test(style) ? 'none' : '';
|
||||
const visibility = /visibility\s*:\s*hidden/.test(style) ? 'hidden' : '';
|
||||
|
||||
const attrMap: Record<string, string | null> = {};
|
||||
for (const key of ['role', 'data-testid', 'data-qa', 'data-action', 'data-element-id'] as const) {
|
||||
if (attrs[key] != null) attrMap[key] = attrs[key] as string;
|
||||
}
|
||||
|
||||
return {
|
||||
tagName: tag.toUpperCase(),
|
||||
children: childEls,
|
||||
childNodes: ownTextNodes,
|
||||
offsetParent: display || visibility ? null : {},
|
||||
id: attrs.id ?? '',
|
||||
type: attrs.type ?? '',
|
||||
name: attrs.name ?? '',
|
||||
href: attrs.href
|
||||
? attrs.href.startsWith('http')
|
||||
? attrs.href
|
||||
: `https://example.com${attrs.href}`
|
||||
: '',
|
||||
checked: !!attrs.checked,
|
||||
disabled: !!attrs.disabled,
|
||||
innerText: deepText,
|
||||
textContent: deepText,
|
||||
getAttribute(name: string) { return attrMap[name] ?? null; },
|
||||
_display: display,
|
||||
_visibility: visibility,
|
||||
};
|
||||
}
|
||||
|
||||
function body(...children: (FakeEl | string)[]): FakeEl {
|
||||
return el('body', {}, ...children);
|
||||
}
|
||||
|
||||
// ── Fake page ──────────────────────────────────────────────────────────────
|
||||
|
||||
function findByTag(root: FakeEl, tag: string): FakeEl | null {
|
||||
if (root.tagName.toLowerCase() === tag.toLowerCase()) return root;
|
||||
for (const child of root.children) {
|
||||
const found = findByTag(child, tag);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function makePage(rootEl: FakeEl) {
|
||||
const fakeDocument = {
|
||||
querySelector(sel: string): FakeEl | null {
|
||||
if (sel.startsWith('#') || sel.startsWith('[') || sel.startsWith('.')) return null;
|
||||
return findByTag(rootEl, sel);
|
||||
},
|
||||
};
|
||||
const fakeWindow = {
|
||||
getComputedStyle: (e: FakeEl) => ({ display: e._display, visibility: e._visibility }),
|
||||
location: { origin: 'https://example.com' },
|
||||
};
|
||||
const fakeNode = { TEXT_NODE: 3 };
|
||||
|
||||
const evaluate = jest.fn().mockImplementation((fn: Function, args: unknown) => {
|
||||
// eslint-disable-next-line no-new-func
|
||||
const exec = new Function(
|
||||
'document', 'window', 'Node', '__args__',
|
||||
`return (${fn.toString()})(__args__)`,
|
||||
);
|
||||
return Promise.resolve(exec(fakeDocument, fakeWindow, fakeNode, args));
|
||||
});
|
||||
|
||||
return { evaluate } as unknown as import('playwright').Page;
|
||||
}
|
||||
|
||||
const dump = (rootEl: FakeEl, sel?: string): Promise<DomNode> =>
|
||||
dumpDom(makePage(rootEl), sel);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('dumpDom', () => {
|
||||
// ── Error handling ────────────────────────────────────────────────────────
|
||||
|
||||
it('returns an ERROR node when the root selector is not found', async () => {
|
||||
const result = await dump(body(el('p', {}, 'hello')), '#does-not-exist');
|
||||
expect(result.tag).toBe('ERROR');
|
||||
expect(result.text).toContain('#does-not-exist');
|
||||
});
|
||||
|
||||
// ── Scope & defaults ──────────────────────────────────────────────────────
|
||||
|
||||
it('defaults to body scope', async () => {
|
||||
const result = await dump(body(el('button', {}, 'Go')));
|
||||
expect(result.tag).toBe('body');
|
||||
});
|
||||
|
||||
it('scopes to an arbitrary sub-selector', async () => {
|
||||
const root = body(
|
||||
el('header', {}, el('a', { href: '/nav' }, 'Nav')),
|
||||
el('main', {}, el('button', {}, 'Action')),
|
||||
);
|
||||
const result = await dump(root, 'main');
|
||||
expect(result.tag).toBe('main');
|
||||
expect(result.children.find(c => c.tag === 'header')).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Visibility filtering ──────────────────────────────────────────────────
|
||||
|
||||
it('skips elements with display:none', async () => {
|
||||
const root = body(
|
||||
el('button', { style: 'display:none' }, 'Hidden'),
|
||||
el('button', {}, 'Visible'),
|
||||
);
|
||||
const result = await dump(root);
|
||||
const btns = result.children.filter(c => c.tag === 'button');
|
||||
expect(btns).toHaveLength(1);
|
||||
expect(btns[0].text).toBe('Visible');
|
||||
});
|
||||
|
||||
it('skips elements with visibility:hidden', async () => {
|
||||
const root = body(
|
||||
el('button', { style: 'visibility:hidden' }, 'Hidden'),
|
||||
el('button', {}, 'Visible'),
|
||||
);
|
||||
const result = await dump(root);
|
||||
const btns = result.children.filter(c => c.tag === 'button');
|
||||
expect(btns).toHaveLength(1);
|
||||
expect(btns[0].text).toBe('Visible');
|
||||
});
|
||||
|
||||
// ── Ignored tag types ─────────────────────────────────────────────────────
|
||||
|
||||
it('skips SVG elements', async () => {
|
||||
const svgEl = el('svg', {}, el('path', {}));
|
||||
const result = await dump(body(el('button', {}, svgEl, 'Click')));
|
||||
const btn = result.children.find(c => c.tag === 'button');
|
||||
expect(btn).toBeDefined();
|
||||
expect(btn!.children.some(c => c.tag === 'svg')).toBe(false);
|
||||
});
|
||||
|
||||
it('skips SCRIPT elements', async () => {
|
||||
const result = await dump(body(el('script', {}, 'alert(1)'), el('button', {}, 'OK')));
|
||||
expect(result.children.some(c => c.tag === 'script')).toBe(false);
|
||||
});
|
||||
|
||||
// ── Semantic attributes ───────────────────────────────────────────────────
|
||||
|
||||
it('captures data-testid', async () => {
|
||||
const result = await dump(body(el('button', { 'data-testid': 'save-btn' }, 'Save')));
|
||||
expect(result.children.find(c => c.tag === 'button')?.testid).toBe('save-btn');
|
||||
});
|
||||
|
||||
it('captures data-qa', async () => {
|
||||
const result = await dump(body(el('div', { 'data-qa': 'process-name' }, el('input', { type: 'text' }))));
|
||||
expect(result.children.find(c => c.qa === 'process-name')).toBeDefined();
|
||||
});
|
||||
|
||||
it('captures data-action', async () => {
|
||||
const result = await dump(body(el('div', { 'data-action': 'append.append-task' })));
|
||||
expect(result.children.find(c => c.action === 'append.append-task')).toBeDefined();
|
||||
});
|
||||
|
||||
it('captures data-element-id', async () => {
|
||||
const result = await dump(body(el('div', { 'data-element-id': 'Activity_1abc' })));
|
||||
expect(result.children.find(c => c.elementId === 'Activity_1abc')).toBeDefined();
|
||||
});
|
||||
|
||||
it('captures role attribute', async () => {
|
||||
const result = await dump(body(el('div', { role: 'dialog' }, el('button', {}, 'OK'))));
|
||||
const dialog = result.children.find(c => c.role === 'dialog');
|
||||
expect(dialog).toBeDefined();
|
||||
expect(dialog!.tag).toBe('div');
|
||||
});
|
||||
|
||||
// ── Interactive element attributes ────────────────────────────────────────
|
||||
|
||||
it('captures input id, type, and name', async () => {
|
||||
const result = await dump(body(el('input', { id: 'email', type: 'email', name: 'userEmail' })));
|
||||
const input = result.children.find(c => c.tag === 'input');
|
||||
expect(input?.id).toBe('email');
|
||||
expect(input?.type).toBe('email');
|
||||
expect(input?.name).toBe('userEmail');
|
||||
});
|
||||
|
||||
it('captures checked:true on a checked checkbox', async () => {
|
||||
const result = await dump(body(el('input', { type: 'checkbox', checked: true })));
|
||||
expect(result.children.find(c => c.tag === 'input')?.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('captures checked:false on an unchecked checkbox', async () => {
|
||||
const result = await dump(body(el('input', { type: 'checkbox' })));
|
||||
expect(result.children.find(c => c.tag === 'input')?.checked).toBe(false);
|
||||
});
|
||||
|
||||
it('captures disabled:true on a disabled button', async () => {
|
||||
const result = await dump(body(el('button', { disabled: true }, 'Nope')));
|
||||
expect(result.children.find(c => c.tag === 'button')?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('does not set disabled for a non-disabled button', async () => {
|
||||
const result = await dump(body(el('button', {}, 'OK')));
|
||||
expect(result.children.find(c => c.tag === 'button')?.disabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not capture type for button elements', async () => {
|
||||
const result = await dump(body(el('button', { type: 'submit' }, 'Go')));
|
||||
expect(result.children.find(c => c.tag === 'button')?.type).toBeUndefined();
|
||||
});
|
||||
|
||||
it('relativizes same-origin anchor href', async () => {
|
||||
const result = await dump(body(el('a', { href: '/workflow/123' }, 'Link')));
|
||||
expect(result.children.find(c => c.tag === 'a')?.href).toBe('/workflow/123');
|
||||
});
|
||||
|
||||
it('keeps full href for cross-origin anchors', async () => {
|
||||
const result = await dump(body(el('a', { href: 'https://other.com/page' }, 'Ext')));
|
||||
expect(result.children.find(c => c.tag === 'a')?.href).toContain('other.com');
|
||||
});
|
||||
|
||||
// ── Text content ──────────────────────────────────────────────────────────
|
||||
|
||||
it('captures own text content of a button', async () => {
|
||||
const result = await dump(body(el('button', {}, 'Save')));
|
||||
expect(result.children.find(c => c.tag === 'button')?.text).toBe('Save');
|
||||
});
|
||||
|
||||
it('truncates text to 80 characters', async () => {
|
||||
const long = 'x'.repeat(100);
|
||||
const result = await dump(body(el('button', {}, long)));
|
||||
expect(result.children.find(c => c.tag === 'button')?.text?.length).toBe(80);
|
||||
});
|
||||
|
||||
it('falls back to innerText when element has no direct text nodes', async () => {
|
||||
// button wraps a span — no direct text node on button, innerText = span text
|
||||
const result = await dump(body(el('button', {}, el('span', {}, 'Nested'))));
|
||||
expect(result.children.find(c => c.tag === 'button')?.text).toBe('Nested');
|
||||
});
|
||||
|
||||
// ── Tree pruning / unwrapping ─────────────────────────────────────────────
|
||||
|
||||
it('unwraps a non-significant div that has exactly one significant child', async () => {
|
||||
const result = await dump(body(el('div', {}, el('button', {}, 'Click'))));
|
||||
const btn = result.children.find(c => c.tag === 'button');
|
||||
expect(btn).toBeDefined();
|
||||
expect(result.children.some(c => c.tag === 'div' && !c.role && !c.testid && !c.qa)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a non-significant div that has more than one significant child', async () => {
|
||||
const result = await dump(body(el('div', {}, el('button', {}, 'A'), el('button', {}, 'B'))));
|
||||
const wrapper = result.children.find(c => c.tag === 'div');
|
||||
expect(wrapper).toBeDefined();
|
||||
expect(wrapper!.children).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('discards non-significant childless elements', async () => {
|
||||
const result = await dump(body(el('div', {}), el('button', {}, 'Keep')));
|
||||
expect(result.children.find(c => c.tag === 'div' && c.children.length === 0)).toBeUndefined();
|
||||
expect(result.children.some(c => c.tag === 'button')).toBe(true);
|
||||
});
|
||||
|
||||
// ── Structural tags ───────────────────────────────────────────────────────
|
||||
|
||||
it('preserves nested structure inside a form', async () => {
|
||||
const result = await dump(
|
||||
body(el('form', {}, el('input', { id: 'n', type: 'text', name: 'name' }), el('button', {}, 'Send'))),
|
||||
);
|
||||
const form = result.children.find(c => c.tag === 'form');
|
||||
expect(form).toBeDefined();
|
||||
expect(form!.children.find(c => c.tag === 'input')).toBeDefined();
|
||||
expect(form!.children.find(c => c.tag === 'button')).toBeDefined();
|
||||
});
|
||||
|
||||
it('preserves dialog element', async () => {
|
||||
const result = await dump(body(el('dialog', { role: 'dialog' }, el('button', {}, 'Close'))));
|
||||
expect(result.children.find(c => c.tag === 'dialog')).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns empty node when root has no visible significant content', async () => {
|
||||
const result = await dumpDom(makePage(el('div', {})), 'div');
|
||||
expect(result.tag).toBe('empty');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user