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:
@@ -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