Files
liqa/test/dom-helpers.spec.ts
T
ars9 f1dadc602a 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
2026-04-07 19:33:21 +03:00

343 lines
14 KiB
TypeScript

/**
* 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');
});
});