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