diff --git a/src/code-executor/code-executor.service.ts b/src/code-executor/code-executor.service.ts index ede7b1a..d55850f 100644 --- a/src/code-executor/code-executor.service.ts +++ b/src/code-executor/code-executor.service.ts @@ -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 { 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 }); diff --git a/src/code-executor/dom-helpers.ts b/src/code-executor/dom-helpers.ts new file mode 100644 index 0000000..f90c4be --- /dev/null +++ b/src/code-executor/dom-helpers.ts @@ -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 { + 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; diff --git a/test/dom-helpers.spec.ts b/test/dom-helpers.spec.ts new file mode 100644 index 0000000..f6a5c58 --- /dev/null +++ b/test/dom-helpers.spec.ts @@ -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 = {}; + 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'); + }); +});