Files
liqa/test/dom-helpers.spec.ts
T
ars9 71bcc8faec feat(scenario): step output, getStepOutput helper, export/import MCP tools, doc restructure
- add output column to ScenarioRunStepEntity; exec step return value is JSON-serialised and stored
- expose helpers.getStepOutput(order) in exec code; negative order is relative to current step
- add get_scenario_run, wait_for_scenario_run, export_scenario, import_scenario MCP tools
- move Architecture, Key descriptors, MCP tools, Scenario, Development docs to docs/
- delete CONTRIBUTING.md (replaced by docs/development.md)
2026-04-08 19:02:36 +03:00

424 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: (...args: unknown[]) => unknown, args: unknown) => {
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");
});
});